import win32com.client import pythoncom # AJOUT CRITIQUE from datetime import datetime, timedelta, date from typing import Dict, List, Optional import threading import time import logging from config import settings, validate_settings import pyodbc from contextlib import contextmanager logger = logging.getLogger(__name__) class SageConnector: def __init__(self, chemin_base, utilisateur="", mot_de_passe=""): self.chemin_base = chemin_base self.utilisateur = utilisateur self.mot_de_passe = mot_de_passe self.cial = None self.sql_server = "OV-FDDDC6\\SAGE100" self.sql_database = "BIJOU" self.sql_conn_string = ( f"DRIVER={{ODBC Driver 17 for SQL Server}};" f"SERVER={self.sql_server};" f"DATABASE={self.sql_database};" f"Trusted_Connection=yes;" f"Encrypt=no;" ) self._lock_com = threading.RLock() # Thread-local storage pour COM self._thread_local = threading.local() # ========================================================================= # GESTION COM THREAD-SAFE # ========================================================================= @contextmanager def _com_context(self): # Vérifier si COM est déjà initialisé pour ce thread if not hasattr(self._thread_local, "com_initialized"): try: pythoncom.CoInitialize() self._thread_local.com_initialized = True logger.debug( f"COM initialisé pour thread {threading.current_thread().name}" ) except Exception as e: logger.error(f"Erreur initialisation COM: {e}") raise try: yield finally: # Ne pas désinitialiser COM ici car le thread peut être réutilisé pass @contextmanager def _get_sql_connection(self): """Context manager pour connexions SQL""" conn = None try: conn = pyodbc.connect(self.sql_conn_string, timeout=10) yield conn except pyodbc.Error as e: logger.error(f"❌ Erreur SQL: {e}") raise RuntimeError(f"Erreur SQL: {str(e)}") finally: if conn: conn.close() def _safe_strip(self, value): """Strip sécurisé pour valeurs SQL""" if value is None: return None if isinstance(value, str): return value.strip() return value def _cleanup_com_thread(self): """Nettoie COM pour le thread actuel (à appeler à la fin)""" if hasattr(self._thread_local, "com_initialized"): try: pythoncom.CoUninitialize() delattr(self._thread_local, "com_initialized") logger.debug( f"COM nettoyé pour thread {threading.current_thread().name}" ) except: pass # ========================================================================= # CONNEXION # ========================================================================= def connecter(self): """Connexion initiale à Sage - VERSION HYBRIDE""" try: # ======================================== # CONNEXION COM (pour écritures) # ======================================== with self._com_context(): self.cial = win32com.client.gencache.EnsureDispatch( "Objets100c.Cial.Stream" ) self.cial.Name = self.chemin_base self.cial.Loggable.UserName = self.utilisateur self.cial.Loggable.UserPwd = self.mot_de_passe self.cial.Open() logger.info(f"✅ Connexion COM Sage réussie: {self.chemin_base}") # ======================================== # TEST CONNEXION SQL (pour lectures) # ======================================== try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM F_COMPTET") nb_tiers = cursor.fetchone()[0] logger.info(f"✅ Connexion SQL réussie: {nb_tiers} tiers détectés") except Exception as e: logger.warning(f"⚠️ SQL non disponible: {e}") logger.warning(" Les lectures utiliseront COM (plus lent)") return True except Exception as e: logger.error(f"❌ Erreur connexion Sage: {e}", exc_info=True) return False def deconnecter(self): """Déconnexion propre""" if self.cial: try: with self._com_context(): self.cial.Close() logger.info("Connexion Sage fermée") except: pass def lister_tous_fournisseurs(self, filtre=""): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Contact FROM F_COMPTET WHERE CT_Type = 1 """ params = [] if filtre: query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() fournisseurs = [] for row in rows: fournisseurs.append( { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": 1, # Fournisseur "est_fournisseur": True, "qualite": self._safe_strip(row.CT_Qualite), "adresse": self._safe_strip(row.CT_Adresse), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "email": self._safe_strip(row.CT_EMail), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "contact": self._safe_strip(row.CT_Contact), } ) logger.info(f"✅ SQL: {len(fournisseurs)} fournisseurs") return fournisseurs except Exception as e: logger.error(f"❌ Erreur SQL fournisseurs: {e}") return [] def lire_fournisseur(self, code): try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Contact, CT_FormeJuridique FROM F_COMPTET WHERE CT_Num = ? AND CT_Type = 1 """, (code.upper(),), ) row = cursor.fetchone() if not row: return None return { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": 1, "est_fournisseur": True, "qualite": self._safe_strip(row.CT_Qualite), "adresse": self._safe_strip(row.CT_Adresse), "complement": self._safe_strip(row.CT_Complement), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "portable": self._safe_strip(row.CT_Portable), "email": self._safe_strip(row.CT_EMail), "telecopie": self._safe_strip(row.CT_Telecopie), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "contact": self._safe_strip(row.CT_Contact), "forme_juridique": self._safe_strip(row.CT_FormeJuridique), } except Exception as e: logger.error(f"❌ Erreur SQL fournisseur {code}: {e}") return None def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 0 : VALIDATION & NETTOYAGE # ======================================== logger.info("🔍 === VALIDATION DONNÉES FOURNISSEUR ===") if not fournisseur_data.get("intitule"): raise ValueError("Le champ 'intitule' est obligatoire") # Nettoyage et troncature (longueurs max Sage) intitule = str(fournisseur_data["intitule"])[:69].strip() num_prop = ( str(fournisseur_data.get("num", "")).upper()[:17].strip() if fournisseur_data.get("num") else "" ) compte = str(fournisseur_data.get("compte_collectif", "401000"))[ :13 ].strip() adresse = str(fournisseur_data.get("adresse", ""))[:35].strip() code_postal = str(fournisseur_data.get("code_postal", ""))[:9].strip() ville = str(fournisseur_data.get("ville", ""))[:35].strip() pays = str(fournisseur_data.get("pays", ""))[:35].strip() telephone = str(fournisseur_data.get("telephone", ""))[:21].strip() email = str(fournisseur_data.get("email", ""))[:69].strip() siret = str(fournisseur_data.get("siret", ""))[:14].strip() tva_intra = str(fournisseur_data.get("tva_intra", ""))[:25].strip() logger.info(f" intitule: '{intitule}' (len={len(intitule)})") logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})") logger.info(f" compte: '{compte}' (len={len(compte)})") # ======================================== # ÉTAPE 1 : CRÉATION OBJET FOURNISSEUR # ======================================== # 🔑 CRITIQUE: Utiliser FactoryFournisseur, PAS FactoryClient ! factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist = factory_fournisseur.Create() fournisseur = win32com.client.CastTo(persist, "IBOFournisseur3") # 🔑 CRITIQUE : Initialiser l'objet fournisseur.SetDefault() logger.info("✅ Objet fournisseur créé et initialisé") # ======================================== # ÉTAPE 2 : CHAMPS OBLIGATOIRES # ======================================== logger.info("📝 Définition des champs obligatoires...") # 1. Intitulé (OBLIGATOIRE) fournisseur.CT_Intitule = intitule logger.debug(f" ✅ CT_Intitule: '{intitule}'") # 2. Type = Fournisseur (1) # ⚠️ NOTE: Sur certaines versions Sage, CT_Type n'existe pas # et le type est automatiquement défini par la factory utilisée try: fournisseur.CT_Type = 1 # 1 = Fournisseur logger.debug(" ✅ CT_Type: 1 (Fournisseur)") except: logger.debug(" ⚠️ CT_Type non défini (géré par FactoryFournisseur)") # 3. Qualité (pour versions récentes Sage) try: fournisseur.CT_Qualite = "FOU" logger.debug(" ✅ CT_Qualite: 'FOU'") except: logger.debug(" ⚠️ CT_Qualite non défini (pas critique)") # 4. Compte général principal (OBLIGATOIRE) try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() # Assigner l'objet CompteG fournisseur.CompteGPrinc = compte_obj logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné") else: logger.warning( f" ⚠️ Compte {compte} introuvable - utilisation défaut" ) except Exception as e: logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}") # 5. Numéro fournisseur (OBLIGATOIRE - générer si vide) if num_prop: fournisseur.CT_Num = num_prop logger.debug(f" ✅ CT_Num fourni: '{num_prop}'") else: # 🔑 CRITIQUE : Générer le numéro automatiquement try: # Méthode 1 : SetDefaultNumPiece (si disponible) if hasattr(fournisseur, "SetDefaultNumPiece"): fournisseur.SetDefaultNumPiece() num_genere = getattr(fournisseur, "CT_Num", "") logger.debug(f" ✅ CT_Num auto-généré: '{num_genere}'") else: # Méthode 2 : GetNextNumero depuis la factory num_genere = factory_fournisseur.GetNextNumero() if num_genere: fournisseur.CT_Num = num_genere logger.debug( f" ✅ CT_Num auto (GetNextNumero): '{num_genere}'" ) else: # Méthode 3 : Fallback - timestamp import time num_genere = f"FOUR{int(time.time()) % 1000000}" fournisseur.CT_Num = num_genere logger.warning(f" ⚠️ CT_Num fallback: '{num_genere}'") except Exception as e: logger.error(f" ❌ Impossible de générer CT_Num: {e}") raise ValueError( "Impossible de générer le numéro fournisseur automatiquement" ) # 6. Catégories (valeurs par défaut) try: if hasattr(fournisseur, "N_CatTarif"): fournisseur.N_CatTarif = 1 if hasattr(fournisseur, "N_CatCompta"): fournisseur.N_CatCompta = 1 if hasattr(fournisseur, "N_Period"): fournisseur.N_Period = 1 logger.debug(" ✅ Catégories (N_*) initialisées") except Exception as e: logger.warning(f" ⚠️ Catégories: {e}") # ======================================== # ÉTAPE 3 : CHAMPS OPTIONNELS # ======================================== logger.info("📝 Définition champs optionnels...") # Adresse (objet IAdresse) if any([adresse, code_postal, ville, pays]): try: adresse_obj = fournisseur.Adresse if adresse: adresse_obj.Adresse = adresse if code_postal: adresse_obj.CodePostal = code_postal if ville: adresse_obj.Ville = ville if pays: adresse_obj.Pays = pays logger.debug(" ✅ Adresse définie") except Exception as e: logger.warning(f" ⚠️ Adresse: {e}") # Télécom (objet ITelecom) if telephone or email: try: telecom_obj = fournisseur.Telecom if telephone: telecom_obj.Telephone = telephone if email: telecom_obj.EMail = email logger.debug(" ✅ Télécom défini") except Exception as e: logger.warning(f" ⚠️ Télécom: {e}") # Identifiants fiscaux if siret: try: fournisseur.CT_Siret = siret logger.debug(f" ✅ SIRET: '{siret}'") except Exception as e: logger.warning(f" ⚠️ SIRET: {e}") if tva_intra: try: fournisseur.CT_Identifiant = tva_intra logger.debug(f" ✅ TVA intra: '{tva_intra}'") except Exception as e: logger.warning(f" ⚠️ TVA: {e}") # Options par défaut try: if hasattr(fournisseur, "CT_Lettrage"): fournisseur.CT_Lettrage = True if hasattr(fournisseur, "CT_Sommeil"): fournisseur.CT_Sommeil = False logger.debug(" ✅ Options par défaut définies") except Exception as e: logger.debug(f" ⚠️ Options: {e}") # ======================================== # ÉTAPE 4 : VÉRIFICATION PRÉ-WRITE # ======================================== logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===") num_avant_write = getattr(fournisseur, "CT_Num", "") if not num_avant_write: logger.error("❌ CRITIQUE: CT_Num toujours vide !") raise ValueError("Le numéro fournisseur (CT_Num) est obligatoire") logger.info(f"✅ CT_Num confirmé: '{num_avant_write}'") # ======================================== # ÉTAPE 5 : ÉCRITURE EN BASE # ======================================== logger.info("💾 Écriture du fournisseur dans Sage...") try: fournisseur.Write() logger.info("✅ Write() réussi !") except Exception as e: error_detail = str(e) # Récupérer l'erreur Sage détaillée try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = ( f"{sage_error.Description} (Code: {sage_error.Number})" ) logger.error(f"❌ Erreur Sage: {error_detail}") except: pass # Analyser l'erreur if ( "doublon" in error_detail.lower() or "existe" in error_detail.lower() ): raise ValueError(f"Ce fournisseur existe déjà : {error_detail}") raise RuntimeError(f"Échec Write(): {error_detail}") # ======================================== # ÉTAPE 6 : RELECTURE & FINALISATION # ======================================== try: fournisseur.Read() except Exception as e: logger.warning(f"⚠️ Impossible de relire: {e}") num_final = getattr(fournisseur, "CT_Num", "") if not num_final: raise RuntimeError("CT_Num vide après Write()") logger.info(f"✅✅✅ FOURNISSEUR CRÉÉ: {num_final} - {intitule} ✅✅✅") # ======================================== # ÉTAPE 7 : CONSTRUCTION RÉPONSE # ======================================== resultat = { "numero": num_final, "intitule": intitule, "compte_collectif": compte, "type": 1, # Fournisseur "est_fournisseur": True, "adresse": adresse or None, "code_postal": code_postal or None, "ville": ville or None, "pays": pays or None, "email": email or None, "telephone": telephone or None, "siret": siret or None, "tva_intra": tva_intra or None, } # ⚠️ PAS DE REFRESH CACHE ICI # Car lister_tous_fournisseurs() utilise FactoryFournisseur.List() # qui lit directement depuis Sage (pas de cache) return resultat except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur création fournisseur: {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE FOURNISSEUR EXISTANT # ======================================== logger.info(f"🔍 Recherche fournisseur {code}...") factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist = factory_fournisseur.ReadNumero(code) if not persist: raise ValueError(f"Fournisseur {code} introuvable") fournisseur = self._cast_client(persist) # ✅ Réutiliser _cast_client if not fournisseur: raise ValueError(f"Impossible de charger le fournisseur {code}") logger.info( f"✅ Fournisseur {code} trouvé: {getattr(fournisseur, 'CT_Intitule', '')}" ) # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS # ======================================== logger.info("📝 Mise à jour des champs...") champs_modifies = [] # Intitulé if "intitule" in fournisseur_data: intitule = str(fournisseur_data["intitule"])[:69].strip() fournisseur.CT_Intitule = intitule champs_modifies.append(f"intitule='{intitule}'") # Adresse if any( k in fournisseur_data for k in ["adresse", "code_postal", "ville", "pays"] ): try: adresse_obj = fournisseur.Adresse if "adresse" in fournisseur_data: adresse = str(fournisseur_data["adresse"])[:35].strip() adresse_obj.Adresse = adresse champs_modifies.append("adresse") if "code_postal" in fournisseur_data: cp = str(fournisseur_data["code_postal"])[:9].strip() adresse_obj.CodePostal = cp champs_modifies.append("code_postal") if "ville" in fournisseur_data: ville = str(fournisseur_data["ville"])[:35].strip() adresse_obj.Ville = ville champs_modifies.append("ville") if "pays" in fournisseur_data: pays = str(fournisseur_data["pays"])[:35].strip() adresse_obj.Pays = pays champs_modifies.append("pays") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour adresse: {e}") # Télécom if "email" in fournisseur_data or "telephone" in fournisseur_data: try: telecom_obj = fournisseur.Telecom if "email" in fournisseur_data: email = str(fournisseur_data["email"])[:69].strip() telecom_obj.EMail = email champs_modifies.append("email") if "telephone" in fournisseur_data: tel = str(fournisseur_data["telephone"])[:21].strip() telecom_obj.Telephone = tel champs_modifies.append("telephone") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour télécom: {e}") # SIRET if "siret" in fournisseur_data: try: siret = str(fournisseur_data["siret"])[:14].strip() fournisseur.CT_Siret = siret champs_modifies.append("siret") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}") # TVA Intracommunautaire if "tva_intra" in fournisseur_data: try: tva = str(fournisseur_data["tva_intra"])[:25].strip() fournisseur.CT_Identifiant = tva champs_modifies.append("tva_intra") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour TVA: {e}") if not champs_modifies: logger.warning("⚠️ Aucun champ à modifier") # Retourner les données actuelles via extraction directe return { "numero": getattr(fournisseur, "CT_Num", "").strip(), "intitule": getattr(fournisseur, "CT_Intitule", "").strip(), "type": 1, "est_fournisseur": True, } logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}") # ======================================== # ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS # ======================================== logger.info("💾 Écriture des modifications...") try: fournisseur.Write() logger.info("✅ Write() réussi !") except Exception as e: error_detail = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = ( f"{sage_error.Description} (Code: {sage_error.Number})" ) except: pass logger.error(f"❌ Erreur Write(): {error_detail}") raise RuntimeError(f"Échec modification: {error_detail}") # ======================================== # ÉTAPE 4 : RELIRE ET RETOURNER # ======================================== fournisseur.Read() logger.info( f"✅✅✅ FOURNISSEUR MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅" ) # Extraction directe (comme lire_fournisseur) numero = getattr(fournisseur, "CT_Num", "").strip() intitule = getattr(fournisseur, "CT_Intitule", "").strip() data = { "numero": numero, "intitule": intitule, "type": 1, "est_fournisseur": True, } # Adresse try: adresse_obj = getattr(fournisseur, "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"] = "" # Télécom try: telecom_obj = getattr(fournisseur, "Telecom", None) if telecom_obj: data["telephone"] = getattr( telecom_obj, "Telephone", "" ).strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() except: data["telephone"] = "" data["email"] = "" return data except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur modification fournisseur: {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") def lister_tous_clients(self, filtre=""): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect, CT_Contact FROM F_COMPTET WHERE CT_Type = 0 """ params = [] if filtre: query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() clients = [] for row in rows: clients.append( { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": row.CT_Type, "qualite": self._safe_strip(row.CT_Qualite), "adresse": self._safe_strip(row.CT_Adresse), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "email": self._safe_strip(row.CT_EMail), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "est_prospect": (row.CT_Prospect == 1), "contact": self._safe_strip(row.CT_Contact), } ) logger.info(f"✅ SQL: {len(clients)} clients") return clients except Exception as e: logger.error(f"❌ Erreur SQL clients: {e}") raise RuntimeError(f"Erreur lecture clients: {str(e)}") def lire_client(self, code_client): try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ✅ MÊME REQUÊTE que lister_tous_clients (colonnes existantes uniquement) cursor.execute( """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect, CT_Contact FROM F_COMPTET WHERE CT_Num = ? """, (code_client.upper(),), ) row = cursor.fetchone() if not row: return None return { "numero": self._safe_strip(row[0]), "intitule": self._safe_strip(row[1]), "type": row[2], "qualite": self._safe_strip(row[3]), "adresse": self._safe_strip(row[4]), "ville": self._safe_strip(row[5]), "code_postal": self._safe_strip(row[6]), "pays": self._safe_strip(row[7]), "telephone": self._safe_strip(row[8]), "email": self._safe_strip(row[9]), "siret": self._safe_strip(row[10]), "tva_intra": self._safe_strip(row[11]), "est_actif": (row[12] == 0), "est_prospect": (row[13] == 1), "contact": self._safe_strip(row[14]), } except Exception as e: logger.error(f"❌ Erreur SQL client {code_client}: {e}") return None def lister_tous_articles(self, filtre="", avec_stock=True): try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ======================================== # ÉTAPE 1 : LIRE LES ARTICLES DE BASE # ======================================== query = """ SELECT AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch, AR_UniteVen, FA_CodeFamille, AR_Sommeil, AR_CodeBarre, AR_Type FROM F_ARTICLE WHERE 1=1 """ params = [] if filtre: query += " AND (AR_Ref LIKE ? OR AR_Design LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY AR_Ref" cursor.execute(query, params) rows = cursor.fetchall() articles = [] for row in rows: article = { "reference": self._safe_strip(row[0]), "designation": self._safe_strip(row[1]), "prix_vente": float(row[2]) if row[2] is not None else 0.0, "prix_achat": float(row[3]) if row[3] is not None else 0.0, "unite_vente": ( str(row[4]).strip() if row[4] is not None else "" ), "famille_code": self._safe_strip(row[5]), "est_actif": (row[6] == 0), "code_ean": self._safe_strip(row[7]), "type_article": row[8] if row[8] is not None else 0, # ✅ CORRECTION : Pas de AR_Stock dans ta base ! "stock_reel": 0.0, "stock_mini": 0.0, "stock_maxi": 0.0, "stock_reserve": 0.0, "stock_commande": 0.0, "stock_disponible": 0.0, } article["code_barre"] = article["code_ean"] articles.append(article) # ======================================== # ÉTAPE 2 : ENRICHIR AVEC STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE) # ======================================== if avec_stock and articles: logger.info( f"📦 Enrichissement stock depuis F_ARTSTOCK pour {len(articles)} articles..." ) try: # Créer un mapping des références references = [ a["reference"] for a in articles if a["reference"] ] if not references: return articles # Requête pour récupérer TOUS les stocks en une fois placeholders = ",".join(["?"] * len(references)) stock_query = f""" SELECT AR_Ref, SUM(ISNULL(AS_QteSto, 0)) as Stock_Total, MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini, MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi, SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve, SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande FROM F_ARTSTOCK WHERE AR_Ref IN ({placeholders}) GROUP BY AR_Ref """ cursor.execute(stock_query, references) stock_rows = cursor.fetchall() stock_map = {} for stock_row in stock_rows: ref = self._safe_strip(stock_row[0]) if ref: stock_map[ref] = { "stock_reel": ( float(stock_row[1]) if stock_row[1] else 0.0 ), "stock_mini": ( float(stock_row[2]) if stock_row[2] else 0.0 ), "stock_maxi": ( float(stock_row[3]) if stock_row[3] else 0.0 ), "stock_reserve": ( float(stock_row[4]) if stock_row[4] else 0.0 ), "stock_commande": ( float(stock_row[5]) if stock_row[5] else 0.0 ), } logger.info( f"✅ Stocks chargés pour {len(stock_map)} articles depuis F_ARTSTOCK" ) # Enrichir les articles for article in articles: if article["reference"] in stock_map: stock_data = stock_map[article["reference"]] article.update(stock_data) article["stock_disponible"] = ( article["stock_reel"] - article["stock_reserve"] ) else: # Article sans stock enregistré article["stock_reel"] = 0.0 article["stock_mini"] = 0.0 article["stock_maxi"] = 0.0 article["stock_reserve"] = 0.0 article["stock_commande"] = 0.0 article["stock_disponible"] = 0.0 except Exception as e: logger.error( f"❌ Erreur lecture F_ARTSTOCK: {e}", exc_info=True ) # Ne pas lever d'exception, retourner les articles sans stock logger.info( f"✅ SQL: {len(articles)} articles (stock={'oui' if avec_stock else 'non'})" ) return articles except Exception as e: logger.error(f"❌ Erreur SQL articles: {e}", exc_info=True) raise RuntimeError(f"Erreur lecture articles: {str(e)}") def lire_article(self, reference): try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch, AR_UniteVen, FA_CodeFamille, AR_Sommeil, AR_CodeBarre, AR_Type FROM F_ARTICLE WHERE AR_Ref = ? """, (reference.upper(),), ) row = cursor.fetchone() if not row: return None article = { "reference": self._safe_strip(row[0]), "designation": self._safe_strip(row[1]), "prix_vente": float(row[2]) if row[2] is not None else 0.0, "prix_achat": float(row[3]) if row[3] is not None else 0.0, "unite_vente": str(row[4]).strip() if row[4] is not None else "", "famille_code": self._safe_strip(row[5]), "est_actif": (row[6] == 0), "code_ean": self._safe_strip(row[7]), "code_barre": self._safe_strip(row[7]), "type_article": row[8] if row[8] is not None else 0, "type_article_libelle": { 0: "Article", 1: "Prestation", 2: "Divers", }.get(row[8] if row[8] is not None else 0, "Article"), # Champs optionnels (initialisés à vide/valeur par défaut) "description": "", "designation_complementaire": "", "poids": 0.0, "volume": 0.0, "tva_code": "", "date_creation": "", "date_modification": "", # Stock initialisé à 0 - sera mis à jour depuis F_ARTSTOCK "stock_reel": 0.0, "stock_mini": 0.0, "stock_maxi": 0.0, "stock_reserve": 0.0, "stock_commande": 0.0, "stock_disponible": 0.0, } # TVA taux (par défaut 20%) article["tva_taux"] = 20.0 # ======================================== # ÉTAPE 2 : LIRE LE STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE) # ======================================== logger.info(f"📦 Lecture stock depuis F_ARTSTOCK pour {reference}...") try: cursor.execute( """ SELECT SUM(ISNULL(AS_QteSto, 0)) as Stock_Total, MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini, MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi, SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve, SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande FROM F_ARTSTOCK WHERE AR_Ref = ? GROUP BY AR_Ref """, (reference.upper(),), ) stock_row = cursor.fetchone() if stock_row: # ✅ STOCK DEPUIS F_ARTSTOCK article["stock_reel"] = ( float(stock_row[0]) if stock_row[0] else 0.0 ) article["stock_mini"] = ( float(stock_row[1]) if stock_row[1] else 0.0 ) article["stock_maxi"] = ( float(stock_row[2]) if stock_row[2] else 0.0 ) # Priorité aux réserves/commandes de F_ARTSTOCK si disponibles stock_reserve_artstock = ( float(stock_row[3]) if stock_row[3] else 0.0 ) stock_commande_artstock = ( float(stock_row[4]) if stock_row[4] else 0.0 ) if stock_reserve_artstock > 0: article["stock_reserve"] = stock_reserve_artstock if stock_commande_artstock > 0: article["stock_commande"] = stock_commande_artstock article["stock_disponible"] = ( article["stock_reel"] - article["stock_reserve"] ) logger.info( f"✅ Stock trouvé dans F_ARTSTOCK: {article['stock_reel']} unités" ) else: logger.info( f"⚠️ Aucun stock trouvé dans F_ARTSTOCK pour {reference}" ) except Exception as e: logger.error(f"❌ Erreur lecture F_ARTSTOCK pour {reference}: {e}") # ======================================== # ÉTAPE 3 : ENRICHIR AVEC LIBELLÉ FAMILLE # ======================================== if article["famille_code"]: try: cursor.execute( "SELECT FA_Intitule FROM F_FAMILLE WHERE FA_CodeFamille = ?", (article["famille_code"],), ) famille_row = cursor.fetchone() if famille_row: article["famille_libelle"] = self._safe_strip( famille_row[0] ) else: article["famille_libelle"] = "" except: article["famille_libelle"] = "" else: article["famille_libelle"] = "" return article except Exception as e: logger.error(f"❌ Erreur SQL article {reference}: {e}") return None def _lire_document_sql(self, numero: str, type_doc: int): try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ======================================== # VÉRIFIER SI DO_Domaine EXISTE # ======================================== do_domaine_existe = False try: cursor.execute( "SELECT TOP 1 DO_Domaine FROM F_DOCENTETE WHERE DO_Type = ?", (type_doc,), ) row_test = cursor.fetchone() if row_test is not None: do_domaine_existe = True except: do_domaine_existe = False # ======================================== # LIRE L'ENTÊTE (avec filtre DO_Domaine si disponible) # ======================================== if do_domaine_existe: query = """ SELECT DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC, DO_Statut, DO_Tiers FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? AND DO_Domaine = 0 """ else: query = """ SELECT DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC, DO_Statut, DO_Tiers FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? """ cursor.execute(query, (numero, type_doc)) row = cursor.fetchone() if not row: # ✅ Si DO_Domaine n'existe pas, vérifier le préfixe if not do_domaine_existe: prefixes_vente = { 0: ["DE"], 10: ["BC"], 30: ["BL"], 50: ["AV", "AR"], 60: ["FA", "FC"], } prefixes_acceptes = prefixes_vente.get(type_doc, []) est_vente = any( numero.upper().startswith(p) for p in prefixes_acceptes ) if not est_vente: logger.warning( f"Document {numero} semble être un document d'achat (préfixe non reconnu)" ) return None # Charger le client client_code = self._safe_strip(row[6]) if row[6] else "" client_intitule = "" if client_code: cursor.execute( """ SELECT CT_Intitule FROM F_COMPTET WHERE CT_Num = ? """, (client_code,), ) client_row = cursor.fetchone() if client_row: client_intitule = self._safe_strip(client_row[0]) # ======================================== # LIRE LES LIGNES # ======================================== cursor.execute( """ SELECT AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT, DL_Remise01REM_Valeur, DL_Remise01REM_Type FROM F_DOCLIGNE WHERE DO_Piece = ? AND DO_Type = ? ORDER BY DL_Ligne """, (numero, type_doc), ) lignes = [] for ligne_row in cursor.fetchall(): ligne = { "article_code": self._safe_strip(ligne_row[0]), "designation": self._safe_strip(ligne_row[1]), "quantite": float(ligne_row[2]) if ligne_row[2] else 0.0, "prix_unitaire_ht": ( float(ligne_row[3]) if ligne_row[3] else 0.0 ), "montant_ligne_ht": ( float(ligne_row[4]) if ligne_row[4] else 0.0 ), } # Remise (si présente) if ligne_row[5]: ligne["remise_pourcentage"] = float(ligne_row[5]) ligne["remise_type"] = int(ligne_row[6]) if ligne_row[6] else 0 else: ligne["remise_pourcentage"] = 0.0 ligne["remise_type"] = 0 lignes.append(ligne) return { "numero": self._safe_strip(row[0]), "reference": self._safe_strip(row[2]), "date": str(row[1]) if row[1] else "", "client_code": client_code, "client_intitule": client_intitule, "total_ht": float(row[3]) if row[3] else 0.0, "total_ttc": float(row[4]) if row[4] else 0.0, "statut": row[5] if row[5] is not None else 0, "lignes": lignes, "nb_lignes": len(lignes), } except Exception as e: logger.error(f"❌ Erreur SQL lecture document {numero}: {e}") return None def _lister_documents_avec_lignes_sql( self, type_doc: int, filtre: str = "", limit: int = None ): try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ======================================== # ÉTAPE 0 : DIAGNOSTIC - Vérifier si DO_Domaine existe # ======================================== do_domaine_existe = False try: cursor.execute( """ SELECT TOP 1 DO_Domaine FROM F_DOCENTETE WHERE DO_Type = ? """, (type_doc,), ) row = cursor.fetchone() if row is not None: do_domaine_existe = True logger.info( f"[SQL] Colonne DO_Domaine détectée (valeur exemple: {row[0]})" ) except Exception as e: logger.info(f"[SQL] Colonne DO_Domaine non disponible: {e}") do_domaine_existe = False # ======================================== # ÉTAPE 1 : CONSTRUIRE LA REQUÊTE SELON DISPONIBILITÉ DO_Domaine # ======================================== if do_domaine_existe: # Version avec filtre DO_Domaine query = """ SELECT d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, d.DO_Statut, d.DO_Tiers, c.CT_Intitule FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num WHERE d.DO_Type = ? AND d.DO_Domaine = 0 """ logger.info(f"[SQL] Requête AVEC filtre DO_Domaine = 0") else: # Version SANS filtre DO_Domaine (utilise heuristique) query = """ SELECT d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, d.DO_Statut, d.DO_Tiers, c.CT_Intitule FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num WHERE d.DO_Type = ? """ logger.warning( f"[SQL] Requête SANS filtre DO_Domaine (heuristique sur préfixe)" ) params = [type_doc] if filtre: query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY d.DO_Date DESC" if limit: query = f"SELECT TOP ({limit}) * FROM ({query}) AS subquery" cursor.execute(query, params) entetes = cursor.fetchall() logger.info( f"[SQL] {len(entetes)} documents bruts récupérés (type={type_doc})" ) documents = [] # ======================================== # ÉTAPE 2 : FILTRER PAR HEURISTIQUE SI DO_Domaine N'EXISTE PAS # ======================================== for entete in entetes: numero = self._safe_strip(entete.DO_Piece) # Si DO_Domaine n'existe pas, filtrer par préfixe du numéro if not do_domaine_existe: # Heuristique : # - Vente (clients) : BC, BL, FA, AV, DE # - Achat (fournisseurs) : DA, RA, FAF, etc. prefixes_vente = { 0: ["DE"], # Devis 10: ["BC"], # Bon de commande 30: ["BL"], # Bon de livraison 50: ["AV", "AR"], # Avoir 60: ["FA", "FC"], # Facture } prefixes_acceptes = prefixes_vente.get(type_doc, []) if prefixes_acceptes: # Vérifier si le numéro commence par un préfixe valide est_vente = any( numero.upper().startswith(p) for p in prefixes_acceptes ) if not est_vente: logger.debug( f"[SQL] Document {numero} exclu (préfixe achat)" ) continue # Créer l'objet document de base doc = { "numero": numero, "reference": self._safe_strip(entete.DO_Ref), "date": str(entete.DO_Date) if entete.DO_Date else "", "client_code": self._safe_strip(entete.DO_Tiers), "client_intitule": self._safe_strip(entete.CT_Intitule), "total_ht": ( float(entete.DO_TotalHT) if entete.DO_TotalHT else 0.0 ), "total_ttc": ( float(entete.DO_TotalTTC) if entete.DO_TotalTTC else 0.0 ), "statut": ( entete.DO_Statut if entete.DO_Statut is not None else 0 ), "lignes": [], } # ======================================== # CHARGER LES LIGNES # ======================================== try: cursor.execute( """ SELECT AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT, DL_Remise01REM_Valeur, DL_Remise01REM_Type FROM F_DOCLIGNE WHERE DO_Piece = ? AND DO_Type = ? ORDER BY DL_Ligne """, (numero, type_doc), ) lignes_rows = cursor.fetchall() for ligne_row in lignes_rows: ligne = { "article_code": self._safe_strip(ligne_row.AR_Ref), "designation": self._safe_strip(ligne_row.DL_Design), "quantite": ( float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0 ), "prix_unitaire_ht": ( float(ligne_row.DL_PrixUnitaire) if ligne_row.DL_PrixUnitaire else 0.0 ), "montant_ligne_ht": ( float(ligne_row.DL_MontantHT) if ligne_row.DL_MontantHT else 0.0 ), } # Remise (si présente) if ligne_row.DL_Remise01REM_Valeur: ligne["remise_pourcentage"] = float( ligne_row.DL_Remise01REM_Valeur ) ligne["remise_type"] = ( int(ligne_row.DL_Remise01REM_Type) if ligne_row.DL_Remise01REM_Type else 0 ) else: ligne["remise_pourcentage"] = 0.0 ligne["remise_type"] = 0 doc["lignes"].append(ligne) except Exception as e: logger.warning(f"Erreur chargement lignes pour {numero}: {e}") # Ajouter le nombre de lignes doc["nb_lignes"] = len(doc["lignes"]) documents.append(doc) methode = "DO_Domaine" if do_domaine_existe else "heuristique prefixe" logger.info( f"SQL: {len(documents)} documents (type={type_doc}, filtre={methode})" ) return documents except Exception as e: logger.error(f"Erreur SQL listage documents avec lignes: {e}") return [] def lister_tous_devis_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=0, filtre=filtre) def lire_devis_cache(self, numero): return self._lire_document_sql(numero, type_doc=0) def lister_toutes_commandes_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=1, filtre=filtre) def lire_commande_cache(self, numero): return self._lire_document_sql(numero, type_doc=1) def lister_toutes_factures_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=6, filtre=filtre) def lire_facture_cache(self, numero): return self._lire_document_sql(numero, type_doc=6) def lister_tous_fournisseurs_cache(self, filtre=""): return self.lister_tous_fournisseurs() def lire_fournisseur_cache(self, code): return self.lire_fournisseur() def lister_toutes_livraisons_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=3, filtre=filtre) def lire_livraison_cache(self, numero): return self._lire_document_sql(numero, type_doc=3) def lister_tous_avoirs_cache(self, filtre=""): return self._lister_documents_avec_lignes_sql(type_doc=5, filtre=filtre) def lire_avoir_cache(self, numero): return self._lire_document_sql(numero, type_doc=5) # ========================================================================= # CAST HELPERS # ========================================================================= def _cast_client(self, persist_obj): try: obj = win32com.client.CastTo(persist_obj, "IBOClient3") obj.Read() return obj except Exception as e: logger.debug(f"❌ _cast_client échoue: {e}") # ✅ AJOUTER CE LOG return None def _cast_article(self, persist_obj): try: obj = win32com.client.CastTo(persist_obj, "IBOArticle3") obj.Read() return obj except: return None def _extraire_client(self, client_obj): try: # === 1. CHAMPS OBLIGATOIRES === try: numero = getattr(client_obj, "CT_Num", "").strip() if not numero: logger.debug("⚠️ Objet sans CT_Num, skip") return None except Exception as e: logger.debug(f"❌ Erreur lecture CT_Num: {e}") return None try: intitule = getattr(client_obj, "CT_Intitule", "").strip() if not intitule: logger.debug(f"⚠️ {numero} sans CT_Intitule") except Exception as e: logger.debug(f"⚠️ Erreur CT_Intitule sur {numero}: {e}") intitule = "" # === 2. CONSTRUCTION OBJET DE BASE === data = { "numero": numero, "intitule": intitule, } # === 3. TYPE DE TIERS (CLIENT/PROSPECT/FOURNISSEUR) === # CT_Qualite : 0=Client, 1=Fournisseur, 2=Client+Fournisseur, 3=Salarié, 4=Prospect try: qualite_code = getattr(client_obj, "CT_Qualite", None) # Mapper les codes vers des libellés qualite_map = { 0: "CLI", # Client 1: "FOU", # Fournisseur 2: "CLIFOU", # Client + Fournisseur 3: "SAL", # Salarié 4: "PRO", # Prospect } data["qualite"] = qualite_map.get(qualite_code, "CLI") data["est_fournisseur"] = qualite_code in [1, 2] except: data["qualite"] = "CLI" data["est_fournisseur"] = False # CT_Prospect : 0=Non, 1=Oui try: data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1 except: data["est_prospect"] = False # Déterminer le type_tiers principal if data["est_prospect"]: data["type_tiers"] = "prospect" elif data["est_fournisseur"] and data["qualite"] != "CLIFOU": data["type_tiers"] = "fournisseur" elif data["qualite"] == "CLIFOU": data["type_tiers"] = "client_fournisseur" else: data["type_tiers"] = "client" # === 4. STATUT (ACTIF / SOMMEIL) === try: sommeil = getattr(client_obj, "CT_Sommeil", 0) data["est_actif"] = sommeil == 0 data["est_en_sommeil"] = sommeil == 1 except: data["est_actif"] = True data["est_en_sommeil"] = False # === 5. TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === try: forme_juridique = getattr(client_obj, "CT_FormeJuridique", "").strip() data["forme_juridique"] = forme_juridique data["est_entreprise"] = bool(forme_juridique) data["est_particulier"] = not bool(forme_juridique) except: data["forme_juridique"] = "" data["est_entreprise"] = False data["est_particulier"] = True # === 6. IDENTITÉ PERSONNE PHYSIQUE (SI PARTICULIER) === try: data["civilite"] = getattr(client_obj, "CT_Civilite", "").strip() except: data["civilite"] = "" try: data["nom"] = getattr(client_obj, "CT_Nom", "").strip() except: data["nom"] = "" try: data["prenom"] = getattr(client_obj, "CT_Prenom", "").strip() except: data["prenom"] = "" # Nom complet formaté (pour particuliers) if data.get("nom") or data.get("prenom"): parts = [] if data.get("civilite"): parts.append(data["civilite"]) if data.get("prenom"): parts.append(data["prenom"]) if data.get("nom"): parts.append(data["nom"]) data["nom_complet"] = " ".join(parts) else: data["nom_complet"] = "" # === 7. CONTACT PRINCIPAL === try: data["contact"] = getattr(client_obj, "CT_Contact", "").strip() except: data["contact"] = "" # === 8. ADRESSE COMPLÈTE === try: adresse_obj = getattr(client_obj, "Adresse", None) if adresse_obj: try: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() except: data["adresse"] = "" try: data["complement"] = getattr( adresse_obj, "Complement", "" ).strip() except: data["complement"] = "" try: data["code_postal"] = getattr( adresse_obj, "CodePostal", "" ).strip() except: data["code_postal"] = "" try: data["ville"] = getattr(adresse_obj, "Ville", "").strip() except: data["ville"] = "" try: data["region"] = getattr(adresse_obj, "Region", "").strip() except: data["region"] = "" try: data["pays"] = getattr(adresse_obj, "Pays", "").strip() except: data["pays"] = "" else: data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" except Exception as e: logger.debug(f"⚠️ Erreur adresse sur {numero}: {e}") data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" # === 9. TÉLÉCOMMUNICATIONS (DISTINCTION FIXE/MOBILE) === try: telecom = getattr(client_obj, "Telecom", None) if telecom: # Téléphone FIXE try: data["telephone"] = getattr(telecom, "Telephone", "").strip() except: data["telephone"] = "" # Téléphone MOBILE try: data["portable"] = getattr(telecom, "Portable", "").strip() except: data["portable"] = "" # FAX try: data["telecopie"] = getattr(telecom, "Telecopie", "").strip() except: data["telecopie"] = "" # EMAIL try: data["email"] = getattr(telecom, "EMail", "").strip() except: data["email"] = "" # SITE WEB try: site = ( getattr(telecom, "Site", None) or getattr(telecom, "Web", None) or getattr(telecom, "SiteWeb", "") ) data["site_web"] = str(site).strip() if site else "" except: data["site_web"] = "" else: data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" except Exception as e: logger.debug(f"⚠️ Erreur telecom sur {numero}: {e}") data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" # === 10. INFORMATIONS JURIDIQUES (ENTREPRISES) === try: data["siret"] = getattr(client_obj, "CT_Siret", "").strip() except: data["siret"] = "" try: data["siren"] = getattr(client_obj, "CT_Siren", "").strip() except: data["siren"] = "" try: data["tva_intra"] = getattr(client_obj, "CT_Identifiant", "").strip() except: data["tva_intra"] = "" try: data["code_naf"] = ( getattr(client_obj, "CT_CodeNAF", "").strip() or getattr(client_obj, "CT_APE", "").strip() ) except: data["code_naf"] = "" # === 11. INFORMATIONS COMMERCIALES === try: data["secteur"] = getattr(client_obj, "CT_Secteur", "").strip() except: data["secteur"] = "" try: effectif = getattr(client_obj, "CT_Effectif", None) data["effectif"] = int(effectif) if effectif is not None else None except: data["effectif"] = None try: ca = getattr(client_obj, "CT_ChiffreAffaire", None) data["ca_annuel"] = float(ca) if ca is not None else None except: data["ca_annuel"] = None # Commercial rattaché try: data["commercial_code"] = getattr(client_obj, "CO_No", "").strip() except: try: data["commercial_code"] = getattr( client_obj, "CT_Commercial", "" ).strip() except: data["commercial_code"] = "" if data.get("commercial_code"): try: commercial_obj = getattr(client_obj, "Commercial", None) if commercial_obj: commercial_obj.Read() data["commercial_nom"] = getattr( commercial_obj, "CO_Nom", "" ).strip() else: data["commercial_nom"] = "" except: data["commercial_nom"] = "" else: data["commercial_nom"] = "" # === 12. CATÉGORIES === try: data["categorie_tarifaire"] = getattr(client_obj, "N_CatTarif", None) except: data["categorie_tarifaire"] = None try: data["categorie_comptable"] = getattr(client_obj, "N_CatCompta", None) except: data["categorie_comptable"] = None # === 13. INFORMATIONS FINANCIÈRES === try: data["encours_autorise"] = float(getattr(client_obj, "CT_Encours", 0.0)) except: data["encours_autorise"] = 0.0 try: data["assurance_credit"] = float( getattr(client_obj, "CT_Assurance", 0.0) ) except: data["assurance_credit"] = 0.0 try: data["compte_general"] = getattr(client_obj, "CG_Num", "").strip() except: data["compte_general"] = "" # === 14. DATES === try: date_creation = getattr(client_obj, "CT_DateCreate", None) data["date_creation"] = str(date_creation) if date_creation else "" except: data["date_creation"] = "" try: date_modif = getattr(client_obj, "CT_DateModif", None) data["date_modification"] = str(date_modif) if date_modif else "" except: data["date_modification"] = "" return data except Exception as e: logger.error(f"❌ ERREUR GLOBALE _extraire_client: {e}", exc_info=True) return None def _extraire_article(self, article_obj): try: data = { # === IDENTIFICATION === "reference": getattr(article_obj, "AR_Ref", "").strip(), "designation": getattr(article_obj, "AR_Design", "").strip(), } # === CODE EAN / CODE-BARRES === data["code_ean"] = "" data["code_barre"] = "" try: # Essayer AR_CodeBarre (champ principal) code_barre = getattr(article_obj, "AR_CodeBarre", "").strip() if code_barre: data["code_ean"] = code_barre data["code_barre"] = code_barre # Sinon essayer AR_CodeBarre1 if not data["code_ean"]: code_barre1 = getattr(article_obj, "AR_CodeBarre1", "").strip() if code_barre1: data["code_ean"] = code_barre1 data["code_barre"] = code_barre1 except: pass # === PRIX === try: data["prix_vente"] = float(getattr(article_obj, "AR_PrixVen", 0.0)) except: data["prix_vente"] = 0.0 try: data["prix_achat"] = float(getattr(article_obj, "AR_PrixAch", 0.0)) except: data["prix_achat"] = 0.0 try: data["prix_revient"] = float( getattr(article_obj, "AR_PrixRevient", 0.0) ) except: data["prix_revient"] = 0.0 # === STOCK === try: data["stock_reel"] = float(getattr(article_obj, "AR_Stock", 0.0)) except: data["stock_reel"] = 0.0 try: data["stock_mini"] = float(getattr(article_obj, "AR_StockMini", 0.0)) except: data["stock_mini"] = 0.0 try: data["stock_maxi"] = float(getattr(article_obj, "AR_StockMaxi", 0.0)) except: data["stock_maxi"] = 0.0 # Stock réservé (en commande client) try: data["stock_reserve"] = float(getattr(article_obj, "AR_QteCom", 0.0)) except: data["stock_reserve"] = 0.0 # Stock en commande fournisseur try: data["stock_commande"] = float( getattr(article_obj, "AR_QteComFou", 0.0) ) except: data["stock_commande"] = 0.0 # Stock disponible (réel - réservé) try: data["stock_disponible"] = data["stock_reel"] - data["stock_reserve"] except: data["stock_disponible"] = data["stock_reel"] # === DESCRIPTIONS === # Commentaire / Description détaillée try: commentaire = getattr(article_obj, "AR_Commentaire", "").strip() data["description"] = commentaire except: data["description"] = "" # Désignation complémentaire try: design2 = getattr(article_obj, "AR_Design2", "").strip() data["designation_complementaire"] = design2 except: data["designation_complementaire"] = "" # === CLASSIFICATION === # Type d'article (0=Article, 1=Prestation, 2=Divers) try: type_art = getattr(article_obj, "AR_Type", 0) data["type_article"] = type_art data["type_article_libelle"] = { 0: "Article", 1: "Prestation", 2: "Divers", }.get(type_art, "Inconnu") except: data["type_article"] = 0 data["type_article_libelle"] = "Article" # Famille try: famille_code = getattr(article_obj, "FA_CodeFamille", "").strip() data["famille_code"] = famille_code # Charger le libellé de la famille si disponible if famille_code: try: famille_obj = getattr(article_obj, "Famille", None) if famille_obj: famille_obj.Read() data["famille_libelle"] = getattr( famille_obj, "FA_Intitule", "" ).strip() else: data["famille_libelle"] = "" except: data["famille_libelle"] = "" else: data["famille_libelle"] = "" except: data["famille_code"] = "" data["famille_libelle"] = "" # === FOURNISSEUR PRINCIPAL === try: fournisseur_code = getattr(article_obj, "CT_Num", "").strip() data["fournisseur_principal"] = fournisseur_code # Charger le nom du fournisseur si disponible if fournisseur_code: try: fourn_obj = getattr(article_obj, "Fournisseur", None) if fourn_obj: fourn_obj.Read() data["fournisseur_nom"] = getattr( fourn_obj, "CT_Intitule", "" ).strip() else: data["fournisseur_nom"] = "" except: data["fournisseur_nom"] = "" else: data["fournisseur_nom"] = "" except: data["fournisseur_principal"] = "" data["fournisseur_nom"] = "" # === UNITÉS === try: data["unite_vente"] = getattr(article_obj, "AR_UniteVen", "").strip() except: data["unite_vente"] = "" try: data["unite_achat"] = getattr(article_obj, "AR_UniteAch", "").strip() except: data["unite_achat"] = "" # === CARACTÉRISTIQUES PHYSIQUES === try: data["poids"] = float(getattr(article_obj, "AR_Poids", 0.0)) except: data["poids"] = 0.0 try: data["volume"] = float(getattr(article_obj, "AR_Volume", 0.0)) except: data["volume"] = 0.0 # === STATUT === try: sommeil = getattr(article_obj, "AR_Sommeil", 0) data["est_actif"] = sommeil == 0 data["en_sommeil"] = sommeil == 1 except: data["est_actif"] = True data["en_sommeil"] = False # === TVA === try: tva_code = getattr(article_obj, "TA_Code", "").strip() data["tva_code"] = tva_code # Essayer de charger le taux try: tva_obj = getattr(article_obj, "Taxe1", None) if tva_obj: tva_obj.Read() data["tva_taux"] = float(getattr(tva_obj, "TA_Taux", 20.0)) else: data["tva_taux"] = 20.0 except: data["tva_taux"] = 20.0 except: data["tva_code"] = "" data["tva_taux"] = 20.0 # === DATES === try: date_creation = getattr(article_obj, "AR_DateCreate", None) data["date_creation"] = str(date_creation) if date_creation else "" except: data["date_creation"] = "" try: date_modif = getattr(article_obj, "AR_DateModif", None) data["date_modification"] = str(date_modif) if date_modif else "" except: data["date_modification"] = "" return data except Exception as e: logger.error(f"❌ Erreur extraction article: {e}", exc_info=True) # Retourner structure minimale en cas d'erreur return { "reference": getattr(article_obj, "AR_Ref", "").strip(), "designation": getattr(article_obj, "AR_Design", "").strip(), "prix_vente": 0.0, "stock_reel": 0.0, "code_ean": "", "description": "", "designation_complementaire": "", "prix_achat": 0.0, "prix_revient": 0.0, "stock_mini": 0.0, "stock_maxi": 0.0, "stock_reserve": 0.0, "stock_commande": 0.0, "stock_disponible": 0.0, "code_barre": "", "type_article": 0, "type_article_libelle": "Article", "famille_code": "", "famille_libelle": "", "fournisseur_principal": "", "fournisseur_nom": "", "unite_vente": "", "unite_achat": "", "poids": 0.0, "volume": 0.0, "est_actif": True, "en_sommeil": False, "tva_code": "", "tva_taux": 20.0, "date_creation": "", "date_modification": "", } def _extraire_fournisseur_enrichi(self, fourn_obj): try: # === IDENTIFICATION === numero = getattr(fourn_obj, "CT_Num", "").strip() if not numero: return None intitule = getattr(fourn_obj, "CT_Intitule", "").strip() data = { "numero": numero, "intitule": intitule, "type": 1, # Fournisseur "est_fournisseur": True, } # === STATUT === try: sommeil = getattr(fourn_obj, "CT_Sommeil", 0) data["est_actif"] = sommeil == 0 data["en_sommeil"] = sommeil == 1 except: data["est_actif"] = True data["en_sommeil"] = False # === ADRESSE PRINCIPALE === try: adresse_obj = getattr(fourn_obj, "Adresse", None) if adresse_obj: data["adresse"] = getattr(adresse_obj, "Adresse", "").strip() data["complement"] = getattr(adresse_obj, "Complement", "").strip() data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip() data["ville"] = getattr(adresse_obj, "Ville", "").strip() data["region"] = getattr(adresse_obj, "Region", "").strip() data["pays"] = getattr(adresse_obj, "Pays", "").strip() # Adresse formatée complète parties_adresse = [] if data["adresse"]: parties_adresse.append(data["adresse"]) if data["complement"]: parties_adresse.append(data["complement"]) if data["code_postal"] or data["ville"]: ville_cp = f"{data['code_postal']} {data['ville']}".strip() if ville_cp: parties_adresse.append(ville_cp) if data["pays"]: parties_adresse.append(data["pays"]) data["adresse_complete"] = ", ".join(parties_adresse) else: data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" data["adresse_complete"] = "" except Exception as e: logger.debug(f"⚠️ Erreur adresse fournisseur {numero}: {e}") data["adresse"] = "" data["complement"] = "" data["code_postal"] = "" data["ville"] = "" data["region"] = "" data["pays"] = "" data["adresse_complete"] = "" # === TÉLÉCOMMUNICATIONS === try: telecom_obj = getattr(fourn_obj, "Telecom", None) if telecom_obj: data["telephone"] = getattr(telecom_obj, "Telephone", "").strip() data["portable"] = getattr(telecom_obj, "Portable", "").strip() data["telecopie"] = getattr(telecom_obj, "Telecopie", "").strip() data["email"] = getattr(telecom_obj, "EMail", "").strip() # Site web try: site = ( getattr(telecom_obj, "Site", None) or getattr(telecom_obj, "Web", None) or getattr(telecom_obj, "SiteWeb", "") ) data["site_web"] = str(site).strip() if site else "" except: data["site_web"] = "" else: data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" except Exception as e: logger.debug(f"⚠️ Erreur telecom fournisseur {numero}: {e}") data["telephone"] = "" data["portable"] = "" data["telecopie"] = "" data["email"] = "" data["site_web"] = "" # === INFORMATIONS FISCALES === # SIRET try: data["siret"] = getattr(fourn_obj, "CT_Siret", "").strip() except: data["siret"] = "" # SIREN (extrait du SIRET si disponible) try: if data["siret"] and len(data["siret"]) >= 9: data["siren"] = data["siret"][:9] else: data["siren"] = getattr(fourn_obj, "CT_Siren", "").strip() except: data["siren"] = "" # TVA Intracommunautaire try: data["tva_intra"] = getattr(fourn_obj, "CT_Identifiant", "").strip() except: data["tva_intra"] = "" # Code NAF/APE try: data["code_naf"] = ( getattr(fourn_obj, "CT_CodeNAF", "").strip() or getattr(fourn_obj, "CT_APE", "").strip() ) except: data["code_naf"] = "" # Forme juridique try: data["forme_juridique"] = getattr( fourn_obj, "CT_FormeJuridique", "" ).strip() except: data["forme_juridique"] = "" # === CATÉGORIES === # Catégorie tarifaire try: cat_tarif = getattr(fourn_obj, "N_CatTarif", None) data["categorie_tarifaire"] = ( int(cat_tarif) if cat_tarif is not None else None ) except: data["categorie_tarifaire"] = None # Catégorie comptable try: cat_compta = getattr(fourn_obj, "N_CatCompta", None) data["categorie_comptable"] = ( int(cat_compta) if cat_compta is not None else None ) except: data["categorie_comptable"] = None # === CONDITIONS DE RÈGLEMENT === try: cond_regl = getattr(fourn_obj, "CT_CondRegl", "").strip() data["conditions_reglement_code"] = cond_regl # Charger le libellé si disponible if cond_regl: try: # Essayer de charger l'objet ConditionReglement cond_obj = getattr(fourn_obj, "ConditionReglement", None) if cond_obj: cond_obj.Read() data["conditions_reglement_libelle"] = getattr( cond_obj, "C_Intitule", "" ).strip() else: data["conditions_reglement_libelle"] = "" except: data["conditions_reglement_libelle"] = "" else: data["conditions_reglement_libelle"] = "" except: data["conditions_reglement_code"] = "" data["conditions_reglement_libelle"] = "" # Mode de règlement try: mode_regl = getattr(fourn_obj, "CT_ModeRegl", "").strip() data["mode_reglement_code"] = mode_regl # Libellé du mode de règlement if mode_regl: try: mode_obj = getattr(fourn_obj, "ModeReglement", None) if mode_obj: mode_obj.Read() data["mode_reglement_libelle"] = getattr( mode_obj, "M_Intitule", "" ).strip() else: data["mode_reglement_libelle"] = "" except: data["mode_reglement_libelle"] = "" else: data["mode_reglement_libelle"] = "" except: data["mode_reglement_code"] = "" data["mode_reglement_libelle"] = "" # === COORDONNÉES BANCAIRES (IBAN) === data["coordonnees_bancaires"] = [] try: # Sage peut avoir plusieurs comptes bancaires factory_banque = getattr(fourn_obj, "FactoryBanque", None) if factory_banque: index = 1 while index <= 5: # Max 5 comptes bancaires try: banque_persist = factory_banque.List(index) if banque_persist is None: break banque = win32com.client.CastTo( banque_persist, "IBOBanque3" ) banque.Read() compte_bancaire = { "banque_nom": getattr( banque, "BI_Intitule", "" ).strip(), "iban": getattr(banque, "RIB_Iban", "").strip(), "bic": getattr(banque, "RIB_Bic", "").strip(), "code_banque": getattr( banque, "RIB_Banque", "" ).strip(), "code_guichet": getattr( banque, "RIB_Guichet", "" ).strip(), "numero_compte": getattr( banque, "RIB_Compte", "" ).strip(), "cle_rib": getattr(banque, "RIB_Cle", "").strip(), } # Ne garder que si IBAN ou RIB complet if ( compte_bancaire["iban"] or compte_bancaire["numero_compte"] ): data["coordonnees_bancaires"].append(compte_bancaire) index += 1 except: break except Exception as e: logger.debug( f"⚠️ Erreur coordonnées bancaires fournisseur {numero}: {e}" ) # IBAN principal (premier de la liste) if data["coordonnees_bancaires"]: data["iban_principal"] = data["coordonnees_bancaires"][0].get( "iban", "" ) data["bic_principal"] = data["coordonnees_bancaires"][0].get("bic", "") else: data["iban_principal"] = "" data["bic_principal"] = "" # === CONTACTS === data["contacts"] = [] try: factory_contact = getattr(fourn_obj, "FactoryContact", None) if factory_contact: index = 1 while index <= 20: # Max 20 contacts try: contact_persist = factory_contact.List(index) if contact_persist is None: break contact = win32com.client.CastTo( contact_persist, "IBOContact3" ) contact.Read() contact_data = { "nom": getattr(contact, "CO_Nom", "").strip(), "prenom": getattr(contact, "CO_Prenom", "").strip(), "fonction": getattr(contact, "CO_Fonction", "").strip(), "service": getattr(contact, "CO_Service", "").strip(), "telephone": getattr( contact, "CO_Telephone", "" ).strip(), "portable": getattr(contact, "CO_Portable", "").strip(), "email": getattr(contact, "CO_EMail", "").strip(), } # Nom complet formaté nom_complet = f"{contact_data['prenom']} {contact_data['nom']}".strip() if nom_complet: contact_data["nom_complet"] = nom_complet else: contact_data["nom_complet"] = contact_data["nom"] # Ne garder que si nom existe if contact_data["nom"]: data["contacts"].append(contact_data) index += 1 except: break except Exception as e: logger.debug(f"⚠️ Erreur contacts fournisseur {numero}: {e}") # Nombre de contacts data["nb_contacts"] = len(data["contacts"]) # Contact principal (premier de la liste) if data["contacts"]: data["contact_principal"] = data["contacts"][0] else: data["contact_principal"] = None # === STATISTIQUES & INFORMATIONS COMMERCIALES === # Encours autorisé try: data["encours_autorise"] = float(getattr(fourn_obj, "CT_Encours", 0.0)) except: data["encours_autorise"] = 0.0 # Chiffre d'affaires annuel try: data["ca_annuel"] = float(getattr(fourn_obj, "CT_ChiffreAffaire", 0.0)) except: data["ca_annuel"] = 0.0 # Compte général try: data["compte_general"] = getattr(fourn_obj, "CG_Num", "").strip() except: data["compte_general"] = "" # === DATES === try: date_creation = getattr(fourn_obj, "CT_DateCreate", None) data["date_creation"] = str(date_creation) if date_creation else "" except: data["date_creation"] = "" try: date_modif = getattr(fourn_obj, "CT_DateModif", None) data["date_modification"] = str(date_modif) if date_modif else "" except: data["date_modification"] = "" return data except Exception as e: logger.error(f"❌ Erreur extraction fournisseur: {e}", exc_info=True) # Retourner structure minimale en cas d'erreur return { "numero": getattr(fourn_obj, "CT_Num", "").strip(), "intitule": getattr(fourn_obj, "CT_Intitule", "").strip(), "type": 1, "est_fournisseur": True, "est_actif": True, "en_sommeil": False, "adresse": "", "complement": "", "code_postal": "", "ville": "", "region": "", "pays": "", "adresse_complete": "", "telephone": "", "portable": "", "telecopie": "", "email": "", "site_web": "", "siret": "", "siren": "", "tva_intra": "", "code_naf": "", "forme_juridique": "", "categorie_tarifaire": None, "categorie_comptable": None, "conditions_reglement_code": "", "conditions_reglement_libelle": "", "mode_reglement_code": "", "mode_reglement_libelle": "", "iban_principal": "", "bic_principal": "", "coordonnees_bancaires": [], "contacts": [], "nb_contacts": 0, "contact_principal": None, "encours_autorise": 0.0, "ca_annuel": 0.0, "compte_general": "", "date_creation": "", "date_modification": "", } def creer_devis_enrichi(self, devis_data: dict, forcer_brouillon: bool = False): if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création devis pour client {devis_data['client']['code']} " f"(brouillon={forcer_brouillon})" ) try: with self._com_context(), self._lock_com: transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("✅ Transaction Sage démarrée") except Exception as e: logger.warning(f"⚠️ BeginTrans échoué: {e}") try: # ===== CRÉATION DOCUMENT ===== process = self.cial.CreateProcess_Document(0) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document devis créé") # ===== DATE ===== import pywintypes if isinstance(devis_data["date_devis"], str): try: date_obj = datetime.fromisoformat(devis_data["date_devis"]) except: date_obj = datetime.now() elif isinstance(devis_data["date_devis"], date): date_obj = datetime.combine( devis_data["date_devis"], datetime.min.time() ) else: date_obj = datetime.now() doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") # ===== CLIENT ===== factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero( devis_data["client"]["code"] ) if not persist_client: raise ValueError( f"❌ Client {devis_data['client']['code']} introuvable" ) client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError( f"❌ Impossible de charger le client {devis_data['client']['code']}" ) doc.SetDefaultClient(client_obj) # ✅ STATUT: Définir SEULEMENT si brouillon demandé doc.DO_Statut = 2 logger.info("📊 Statut forcé: 0 (Brouillon)") # Sinon, laisser Sage décider (généralement 2 = Accepté) doc.Write() logger.info(f"👤 Client {devis_data['client']['code']} associé") # ===== LIGNES ===== try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle logger.info(f"📦 Ajout de {len(devis_data['lignes'])} lignes...") for idx, ligne_data in enumerate(devis_data["lignes"], 1): logger.debug( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # Charger l'article persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"❌ Article {ligne_data['article_code']} introuvable" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) designation_sage = getattr(article_obj, "AR_Design", "") if prix_sage == 0: logger.warning( f"⚠️ Article {ligne_data['article_code']} a un prix = 0€" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite # Prix prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) prix_a_utiliser = ligne_data.get("prix_unitaire_ht") if prix_a_utiliser is not None and prix_a_utiliser > 0: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) elif prix_auto == 0: if prix_sage == 0: raise ValueError( f"Prix nul pour article {ligne_data['article_code']}" ) ligne_obj.DL_PrixUnitaire = float(prix_sage) # Remise remise = ligne_data.get("remise_pourcentage", 0) if remise > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") ligne_obj.Write() logger.info(f"✅ {len(devis_data['lignes'])} lignes écrites") # ===== VALIDATION ===== doc.Write() # ✅ PROCESS() uniquement si pas en brouillon if not forcer_brouillon: logger.info("🔄 Lancement Process()...") process.Process() else: # En brouillon, Process() peut échouer, on essaie mais on ignore l'erreur try: process.Process() logger.info("✅ Process() appelé (brouillon)") except: logger.debug("⚠️ Process() ignoré pour brouillon") # ===== RÉCUPÉRATION NUMÉRO ===== numero_devis = None # Méthode 1: DocumentResult try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_devis = getattr(doc_result, "DO_Piece", "") except: pass # Méthode 2: Document direct if not numero_devis: numero_devis = getattr(doc, "DO_Piece", "") # Méthode 3: SetDefaultNumPiece if not numero_devis: try: doc.SetDefaultNumPiece() doc.Write() doc.Read() numero_devis = getattr(doc, "DO_Piece", "") except: pass if not numero_devis: raise RuntimeError("❌ Numéro devis vide après création") logger.info(f"📄 Numéro: {numero_devis}") # ===== COMMIT ===== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info("✅ Transaction committée") except: pass # ===== RELECTURE SIMPLIFIÉE (pas d'attente excessive) ===== # Attendre juste 500ms pour l'indexation time.sleep(0.5) factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece(0, numero_devis) if not persist_reread: # Si ReadPiece échoue, chercher dans les 100 premiers logger.debug("ReadPiece échoué, recherche dans List()...") index = 1 while index < 100: try: persist_test = factory_doc.List(index) if persist_test is None: break doc_test = win32com.client.CastTo( persist_test, "IBODocumentVente3" ) doc_test.Read() if ( getattr(doc_test, "DO_Type", -1) == 0 and getattr(doc_test, "DO_Piece", "") == numero_devis ): persist_reread = persist_test logger.info(f"✅ Document trouvé à l'index {index}") break index += 1 except: index += 1 # Extraction des totaux if persist_reread: doc_final = win32com.client.CastTo( persist_reread, "IBODocumentVente3" ) doc_final.Read() total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) statut_final = getattr(doc_final, "DO_Statut", 0) else: # Fallback: calculer manuellement total_calcule = sum( l.get("montant_ligne_ht", 0) for l in devis_data["lignes"] ) total_ht = total_calcule total_ttc = round(total_calcule * 1.20, 2) statut_final = 0 if forcer_brouillon else 2 logger.info(f"💰 Total HT: {total_ht}€") logger.info(f"💰 Total TTC: {total_ttc}€") logger.info(f"📊 Statut final: {statut_final}") logger.info( f"✅ ✅ ✅ DEVIS CRÉÉ: {numero_devis} - {total_ttc}€ TTC ✅ ✅ ✅" ) return { "numero_devis": numero_devis, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(devis_data["lignes"]), "client_code": devis_data["client"]["code"], "date_devis": str(date_obj.date()), "statut": statut_final, } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() logger.error("❌ Transaction annulée (rollback)") except: pass raise except Exception as e: logger.error(f"❌ ERREUR CRÉATION DEVIS: {e}", exc_info=True) raise RuntimeError(f"Échec création devis: {str(e)}") def lire_devis(self, numero_devis): try: # Lire le devis via SQL devis = self._lire_document_sql(numero_devis, type_doc=0) if not devis: return None # ✅ Vérifier si transformé (via SQL pour DO_Ref) try: verification = self.verifier_si_deja_transforme_sql(numero_devis, 0) devis["a_deja_ete_transforme"] = verification.get( "deja_transforme", False ) devis["documents_cibles"] = verification.get("documents_cibles", []) except Exception as e: logger.warning(f"⚠️ Erreur vérification transformation: {e}") devis["a_deja_ete_transforme"] = False devis["documents_cibles"] = [] logger.info( f"✅ SQL: Devis {numero_devis} lu ({len(devis['lignes'])} lignes)" ) return devis except Exception as e: logger.error(f"❌ Erreur SQL lecture devis {numero_devis}: {e}") return None def lire_document(self, numero, type_doc): return self._lire_document_sql(numero, type_doc) def verifier_si_deja_transforme_sql( self, numero_source: str, type_source: int ) -> Dict: try: with self._get_sql_connection() as conn: cursor = conn.cursor() # Types cibles selon le type source types_cibles_map = { 0: [10, 60], # Devis → Commande ou Facture 10: [30, 60], # Commande → BL ou Facture 30: [60], # BL → Facture } types_cibles = types_cibles_map.get(type_source, []) if not types_cibles: return {"deja_transforme": False, "documents_cibles": []} # Construire la clause WHERE avec OR pour chaque type placeholders = ",".join(["?"] * len(types_cibles)) query = f""" SELECT DO_Piece, DO_Type, DO_Date, DO_Ref, DO_TotalTTC, DO_Statut FROM F_DOCENTETE WHERE DO_Ref LIKE ? AND DO_Type IN ({placeholders}) ORDER BY DO_Date DESC """ params = [f"%{numero_source}%"] + types_cibles cursor.execute(query, params) rows = cursor.fetchall() documents_cibles = [] for row in rows: # Vérifier que DO_Ref correspond exactement (éviter les faux positifs) ref_origine = self._safe_strip(row.DO_Ref) if numero_source in ref_origine or ref_origine == numero_source: type_libelle = { 10: "Bon de commande", 30: "Bon de livraison", 60: "Facture", }.get(row.DO_Type, f"Type {row.DO_Type}") documents_cibles.append( { "numero": self._safe_strip(row.DO_Piece), "type": row.DO_Type, "type_libelle": type_libelle, "date": str(row.DO_Date) if row.DO_Date else "", "reference": ref_origine, "total_ttc": ( float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0 ), "statut": ( row.DO_Statut if row.DO_Statut is not None else 0 ), "methode_detection": "sql_do_ref", } ) logger.info( f"✅ SQL: Vérification {numero_source} → {len(documents_cibles)} transformation(s)" ) return { "deja_transforme": len(documents_cibles) > 0, "nb_transformations": len(documents_cibles), "documents_cibles": documents_cibles, } except Exception as e: logger.error(f"❌ Erreur SQL vérification transformation: {e}") return {"deja_transforme": False, "documents_cibles": []} def _get_type_libelle(self, type_doc: int) -> str: """Retourne le libellé d'un type de document""" types = { 0: "Devis", 10: "Bon de commande", 20: "Préparation", 30: "Bon de livraison", 40: "Bon de retour", 50: "Bon d'avoir", 60: "Facture", } return types.get(type_doc, f"Type {type_doc}") def transformer_document( self, numero_source, type_source, type_cible, ignorer_controle_stock=False ): if not self.cial: raise RuntimeError("Connexion Sage non établie") type_source = int(type_source) type_cible = int(type_cible) logger.info( f"[TRANSFORM] {numero_source} (type {type_source}) → type {type_cible}" ) transformations_valides = { (0, 10), # Devis → Commande (0, 60), # Devis → Facture (10, 30), # Commande → Bon de livraison (10, 60), # Commande → Facture (30, 60), # Bon de livraison → Facture } if (type_source, type_cible) not in transformations_valides: raise ValueError( f"Transformation non autorisée: {type_source} -> {type_cible}" ) # ======================================== # FONCTION UTILITAIRE # ======================================== def lire_erreurs_sage(obj, nom_obj=""): """Lit toutes les erreurs d'un objet Sage COM""" erreurs = [] try: if not hasattr(obj, "Errors") or obj.Errors is None: return erreurs nb_erreurs = 0 try: nb_erreurs = obj.Errors.Count except: return erreurs if nb_erreurs == 0: return erreurs for i in range(1, nb_erreurs + 1): try: err = None try: err = obj.Errors.Item(i) except: try: err = obj.Errors(i) except: try: err = obj.Errors.Item(i - 1) except: pass if err is not None: description = "" field = "" number = "" for attr in ["Description", "Descr", "Message", "Text"]: try: val = getattr(err, attr, None) if val: description = str(val) break except: pass for attr in ["Field", "FieldName", "Champ", "Property"]: try: val = getattr(err, attr, None) if val: field = str(val) break except: pass for attr in ["Number", "Code", "ErrorCode", "Numero"]: try: val = getattr(err, attr, None) if val is not None: number = str(val) break except: pass if description or field or number: erreurs.append( { "source": nom_obj, "index": i, "description": description or "Erreur inconnue", "field": field or "?", "number": number or "?", } ) except Exception as e: logger.debug(f"Erreur lecture erreur {i}: {e}") continue except Exception as e: logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}") return erreurs # Vérification doublons logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...") verification = self.verifier_si_deja_transforme_sql(numero_source, type_source) if verification["deja_transforme"]: docs_existants = verification["documents_cibles"] docs_meme_type = [d for d in docs_existants if d["type"] == type_cible] if docs_meme_type: nums = [d["numero"] for d in docs_meme_type] error_msg = ( f"❌ Le document {numero_source} a déjà été transformé " f"en {self._get_type_libelle(type_cible)}. " f"Document(s) existant(s) : {', '.join(nums)}" ) logger.error(f"[TRANSFORM] {error_msg}") raise ValueError(error_msg) try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : LIRE LE DOCUMENT SOURCE # ======================================== factory = self.cial.FactoryDocumentVente persist_source = factory.ReadPiece(type_source, numero_source) if not persist_source: persist_source = self._find_document_in_list( numero_source, type_source ) if not persist_source: raise ValueError( f"Document {numero_source} (type {type_source}) introuvable" ) doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") doc_source.Read() statut_actuel = getattr(doc_source, "DO_Statut", 0) logger.info( f"[TRANSFORM] Source: type={type_source}, statut={statut_actuel}" ) # ======================================== # ÉTAPE 2 : EXTRAIRE DONNÉES SOURCE # ======================================== logger.info("[TRANSFORM] Extraction données source...") # Client client_code = "" client_obj_source = None try: client_obj_source = getattr(doc_source, "Client", None) if client_obj_source: client_obj_source.Read() client_code = getattr(client_obj_source, "CT_Num", "").strip() except Exception as e: logger.error(f"Erreur lecture client source: {e}") raise ValueError("Impossible de lire le client du document source") if not client_code: raise ValueError("Client introuvable dans document source") logger.info(f"[TRANSFORM] Client: {client_code}") # Date et référence date_source = getattr(doc_source, "DO_Date", None) reference_pour_cible = numero_source logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}") # Champs à copier champs_source = {} champs_a_copier = [ "DO_Souche", "DO_Regime", "DO_CodeJournal", "DO_Coord01", "DO_TypeCalcul", "DO_Devise", "DO_Cours", "DO_Period", "DO_Expedit", "DO_NbFacture", "DO_BLFact", "DO_TxEsworte", "DO_Reliquat", "DO_Imprim", "DO_Ventile", "DO_Motif", ] for champ in champs_a_copier: try: val = getattr(doc_source, champ, None) if val is not None: champs_source[champ] = val except: pass # Infos règlement client client_mode_regl = None client_cond_regl = None if client_obj_source: try: client_mode_regl = getattr( client_obj_source, "CT_ModeRegl", None ) if client_mode_regl: logger.info( f"[TRANSFORM] Mode règlement client: {client_mode_regl}" ) except: pass try: client_cond_regl = getattr( client_obj_source, "CT_CondRegl", None ) if client_cond_regl: logger.info( f"[TRANSFORM] Conditions règlement client: {client_cond_regl}" ) except: pass # ======================================== # ÉTAPE 3 : EXTRACTION LIGNES # ======================================== lignes_source = [] factory_article = self.cial.FactoryArticle try: factory_lignes_source = getattr( doc_source, "FactoryDocumentLigne", None ) if not factory_lignes_source: factory_lignes_source = getattr( doc_source, "FactoryDocumentVenteLigne", None ) if factory_lignes_source: index = 1 while index <= 1000: try: ligne_p = factory_lignes_source.List(index) if ligne_p is None: break ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() # 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 # Prix unitaire prix_unitaire = float( getattr(ligne, "DL_PrixUnitaire", 0.0) ) # Si prix = 0, récupérer depuis l'article if prix_unitaire == 0 and article_ref: try: persist_article = factory_article.ReadReference( article_ref ) if persist_article: article_obj_price = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj_price.Read() prix_unitaire = float( getattr( article_obj_price, "AR_PrixVen", 0.0 ) ) logger.info( f" Prix récupéré depuis article {article_ref}: {prix_unitaire}€" ) except: pass # ✅ NOUVEAU : Récupérer le DL_MvtStock de la ligne source mvt_stock_source = 0 try: mvt_stock_source = int( getattr(ligne, "DL_MvtStock", 0) ) except: pass lignes_source.append( { "article_ref": article_ref, "designation": getattr(ligne, "DL_Design", ""), "quantite": float( getattr(ligne, "DL_Qte", 0.0) ), "prix_unitaire": prix_unitaire, "remise": float( getattr(ligne, "DL_Remise01REM_Valeur", 0.0) ), "type_remise": int( getattr(ligne, "DL_Remise01REM_Type", 0) ), "montant_ht": float( getattr(ligne, "DL_MontantHT", 0.0) ), "mvt_stock": mvt_stock_source, # ✅ Conservé ! } ) index += 1 except Exception as e: logger.debug(f"Erreur ligne {index}: {e}") break except Exception as e: logger.error(f"Erreur extraction lignes: {e}") raise ValueError( "Impossible d'extraire les lignes du document source" ) nb_lignes = len(lignes_source) logger.info(f"[TRANSFORM] {nb_lignes} lignes extraites") if nb_lignes == 0: raise ValueError("Document source vide (aucune ligne)") total_attendu_ht = sum(l["montant_ht"] for l in lignes_source) logger.info( f"[TRANSFORM] Total HT attendu (calculé): {total_attendu_ht}€" ) # ======================================== # ÉTAPE 4 : TRANSACTION # ======================================== transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("[TRANSFORM] Transaction démarrée") except: logger.debug("[TRANSFORM] BeginTrans non disponible") try: # ======================================== # ÉTAPE 5 : CRÉER DOCUMENT CIBLE # ======================================== logger.info(f"[TRANSFORM] Création document type {type_cible}...") process = self.cial.CreateProcess_Document(type_cible) if not process: raise RuntimeError( f"CreateProcess_Document({type_cible}) a retourné None" ) doc_cible = process.Document try: doc_cible = win32com.client.CastTo( doc_cible, "IBODocumentVente3" ) except: pass logger.info("[TRANSFORM] Document cible créé") # ======================================== # ÉTAPE 6 : DÉFINIR LA DATE # ======================================== import pywintypes if date_source: try: doc_cible.DO_Date = date_source logger.info(f"[TRANSFORM] Date copiée: {date_source}") except Exception as e: logger.warning(f"Impossible de copier date: {e}") doc_cible.DO_Date = pywintypes.Time(datetime.now()) else: doc_cible.DO_Date = pywintypes.Time(datetime.now()) # ======================================== # ÉTAPE 7 : ASSOCIER LE CLIENT # ======================================== logger.info(f"[TRANSFORM] Association client {client_code}...") factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero(client_code) if not persist_client: raise ValueError(f"Client {client_code} introuvable") client_obj_cible = self._cast_client(persist_client) if not client_obj_cible: raise ValueError(f"Impossible de charger client {client_code}") try: doc_cible.SetDefaultClient(client_obj_cible) logger.info("[TRANSFORM] SetDefaultClient() appelé") except Exception as e: try: doc_cible.Client = client_obj_cible logger.info( "[TRANSFORM] Client assigné via propriété .Client" ) except Exception as e2: raise ValueError(f"Impossible d'associer le client: {e2}") # DO_Ref AVANT 1er Write try: doc_cible.DO_Ref = reference_pour_cible logger.info( f"[TRANSFORM] DO_Ref défini: {reference_pour_cible}" ) except Exception as e: logger.warning(f"Impossible de définir DO_Ref: {e}") # ======================================== # ÉTAPE 7.5 : COPIER CHAMPS # ======================================== for champ, valeur in champs_source.items(): try: setattr(doc_cible, champ, valeur) logger.debug(f"[TRANSFORM] {champ} copié: {valeur}") except Exception as e: logger.debug(f"[TRANSFORM] {champ} non copié: {e}") # ======================================== # ÉTAPE 7.6 : CHAMPS SPÉCIFIQUES FACTURES # ======================================== if type_cible == 60: logger.info("[TRANSFORM] Configuration champs factures...") # DO_Souche try: souche = champs_source.get("DO_Souche", 0) doc_cible.DO_Souche = souche logger.debug(f" ✅ DO_Souche: {souche}") except Exception as e: logger.debug(f" ⚠️ DO_Souche: {e}") # DO_Regime try: regime = champs_source.get("DO_Regime", 0) doc_cible.DO_Regime = regime logger.debug(f" ✅ DO_Regime: {regime}") except Exception as e: logger.debug(f" ⚠️ DO_Regime: {e}") # DO_Transaction try: doc_cible.DO_Transaction = 11 logger.debug(f" ✅ DO_Transaction: 11") except Exception as e: logger.debug(f" ⚠️ DO_Transaction: {e}") # Mode règlement if client_mode_regl: try: doc_cible.DO_ModeRegl = client_mode_regl logger.info(f" ✅ DO_ModeRegl: {client_mode_regl}") except Exception as e: logger.debug(f" ⚠️ DO_ModeRegl: {e}") # Conditions règlement if client_cond_regl: try: doc_cible.DO_CondRegl = client_cond_regl logger.info(f" ✅ DO_CondRegl: {client_cond_regl}") except Exception as e: logger.debug(f" ⚠️ DO_CondRegl: {e}") # 1er Write doc_cible.Write() logger.info("[TRANSFORM] Document initialisé (1er Write)") # ======================================== # ÉTAPE 8 : COPIER LIGNES (AVEC GESTION STOCK) # ======================================== logger.info(f"[TRANSFORM] Copie de {nb_lignes} lignes...") if ignorer_controle_stock: logger.info("[TRANSFORM] ⚠️ Contrôle de stock DÉSACTIVÉ") try: factory_lignes_cible = doc_cible.FactoryDocumentLigne except: factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne lignes_creees = 0 for idx, ligne_data in enumerate(lignes_source, 1): logger.debug(f"[TRANSFORM] Ligne {idx}/{nb_lignes}") article_ref = ligne_data["article_ref"] if not article_ref: logger.warning( f"Ligne {idx}: pas de référence article, skip" ) continue persist_article = factory_article.ReadReference(article_ref) if not persist_article: logger.warning( f"Ligne {idx}: article {article_ref} introuvable, skip" ) continue article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() ligne_persist = factory_lignes_cible.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = ligne_data["quantite"] prix = ligne_data["prix_unitaire"] # ✅ CRITIQUE : Associer l'article AVANT de définir les flags stock try: ligne_obj.SetDefaultArticleReference(article_ref, quantite) logger.debug(f" SetDefaultArticleReference OK") except Exception as e1: logger.debug(f" SetDefaultArticleReference échoué: {e1}") try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.debug(f" SetDefaultArticle OK") except Exception as e2: logger.debug(f" SetDefaultArticle échoué: {e2}") ligne_obj.DL_Design = ligne_data["designation"] ligne_obj.DL_Qte = quantite logger.debug(f" Configuration manuelle") # ✅ APRÈS association : Désactiver le contrôle de stock if ignorer_controle_stock: # Méthode 1 : Copier DL_MvtStock depuis la source try: mvt_stock_source = ligne_data.get("mvt_stock", 0) ligne_obj.DL_MvtStock = mvt_stock_source logger.debug( f" ✅ DL_MvtStock = {mvt_stock_source} (copié depuis source)" ) except Exception as e: logger.debug(f" ⚠️ DL_MvtStock: {e}") # Méthode 2 : DL_NoStock = 1 try: ligne_obj.DL_NoStock = 1 logger.debug(f" ✅ DL_NoStock = 1") except Exception as e: logger.debug(f" ⚠️ DL_NoStock: {e}") # ✅ MÉTHODE 3 CRITIQUE : DL_NonLivre = quantité # Indique que la quantité n'est PAS livrée, donc pas de sortie de stock try: ligne_obj.DL_NonLivre = quantite logger.debug( f" ✅ DL_NonLivre = {quantite} (évite sortie stock)" ) except Exception as e: logger.debug(f" ⚠️ DL_NonLivre: {e}") # Prix unitaire if prix > 0: ligne_obj.DL_PrixUnitaire = float(prix) logger.debug(f" Prix forcé: {prix}€") # Remise remise = ligne_data["remise"] if remise > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = ligne_data[ "type_remise" ] logger.debug(f" Remise: {remise}%") except: pass # Écrire la ligne try: ligne_obj.Write() lignes_creees += 1 logger.debug(f" ✅ Ligne {idx} écrite") except Exception as e: logger.error(f" ❌ Erreur écriture ligne {idx}: {e}") # Lire erreurs erreurs_ligne = lire_erreurs_sage(ligne_obj, f"Ligne_{idx}") for err in erreurs_ligne: logger.error( f" {err['field']}: {err['description']}" ) continue logger.info(f"[TRANSFORM] {lignes_creees} lignes créées") if lignes_creees == 0: raise ValueError("Aucune ligne n'a pu être créée") # ======================================== # ÉTAPE 9 : WRITE FINAL # ======================================== logger.info("[TRANSFORM] Write() final avant Process()...") doc_cible.Write() # ======================================== # ÉTAPE 10 : PROCESS() # ======================================== logger.info("[TRANSFORM] Appel Process()...") try: process.Process() logger.info("[TRANSFORM] Process() réussi !") except Exception as e: logger.error(f"[TRANSFORM] ERREUR Process(): {e}") toutes_erreurs = [] # Erreurs du process erreurs_process = lire_erreurs_sage(process, "Process") if erreurs_process: logger.error( f"[TRANSFORM] {len(erreurs_process)} erreur(s) process:" ) for err in erreurs_process: logger.error( f" {err['field']}: {err['description']} (code: {err['number']})" ) toutes_erreurs.append( f"{err['field']}: {err['description']}" ) # Erreurs du document erreurs_doc = lire_erreurs_sage(doc_cible, "Document") if erreurs_doc: logger.error( f"[TRANSFORM] {len(erreurs_doc)} erreur(s) document:" ) for err in erreurs_doc: logger.error( f" {err['field']}: {err['description']} (code: {err['number']})" ) toutes_erreurs.append( f"{err['field']}: {err['description']}" ) # Construire message if toutes_erreurs: error_msg = ( f"Process() échoué: {' | '.join(toutes_erreurs)}" ) else: error_msg = f"Process() échoué: {str(e)}" # Conseil stock if "stock" in error_msg.lower() or "2881" in error_msg: error_msg += " | CONSEIL: Vérifiez le stock ou créez le document manuellement dans Sage." logger.error(f"[TRANSFORM] {error_msg}") raise RuntimeError(error_msg) # ======================================== # ÉTAPE 11 : RÉCUPÉRER NUMÉRO # ======================================== numero_cible = None total_ht_final = 0.0 total_ttc_final = 0.0 # DocumentResult try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_cible = getattr(doc_result, "DO_Piece", "") total_ht_final = float( getattr(doc_result, "DO_TotalHT", 0.0) ) total_ttc_final = float( getattr(doc_result, "DO_TotalTTC", 0.0) ) logger.info( f"[TRANSFORM] DocumentResult: {numero_cible}, {total_ht_final}€ HT" ) except Exception as e: logger.debug(f"[TRANSFORM] DocumentResult non disponible: {e}") # Relire doc_cible if not numero_cible: try: doc_cible.Read() numero_cible = getattr(doc_cible, "DO_Piece", "") total_ht_final = float( getattr(doc_cible, "DO_TotalHT", 0.0) ) total_ttc_final = float( getattr(doc_cible, "DO_TotalTTC", 0.0) ) logger.info( f"[TRANSFORM] doc_cible relu: {numero_cible}, {total_ht_final}€ HT" ) except: pass if not numero_cible: raise RuntimeError("Numéro document cible vide après Process()") logger.info(f"[TRANSFORM] Document cible créé: {numero_cible}") logger.info( f"[TRANSFORM] Totaux: {total_ht_final}€ HT / {total_ttc_final}€ TTC" ) # ======================================== # ÉTAPE 12 : COMMIT # ======================================== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info("[TRANSFORM] Transaction committée") except: pass time.sleep(1) logger.info( f"[TRANSFORM] ✅ SUCCÈS: {numero_source} ({type_source}) -> " f"{numero_cible} ({type_cible}) - {lignes_creees} lignes" ) return { "success": True, "document_source": numero_source, "document_cible": numero_cible, "nb_lignes": lignes_creees, "total_ht": total_ht_final, "total_ttc": total_ttc_final, } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() logger.error("[TRANSFORM] Transaction annulée (rollback)") except: pass raise except Exception as e: logger.error(f"[TRANSFORM] ❌ ERREUR: {e}", exc_info=True) raise RuntimeError(f"Echec transformation: {str(e)}") def _find_document_in_list(self, numero, type_doc): """Cherche un document dans List() si ReadPiece échoue""" try: factory = self.cial.FactoryDocumentVente index = 1 while index < 10000: try: persist = factory.List(index) if persist is None: break doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() if ( getattr(doc, "DO_Type", -1) == type_doc and getattr(doc, "DO_Piece", "") == numero ): logger.info(f"[TRANSFORM] Document trouve a l'index {index}") return persist index += 1 except: index += 1 continue return None except Exception as e: logger.error(f"[TRANSFORM] Erreur recherche document: {e}") return None def mettre_a_jour_champ_libre(self, doc_id, type_doc, nom_champ, valeur): """Mise à jour champ libre pour Universign ID""" try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(type_doc, doc_id) if persist: doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() try: setattr(doc, f"DO_{nom_champ}", valeur) doc.Write() logger.debug(f"Champ libre {nom_champ} = {valeur} sur {doc_id}") return True except Exception as e: logger.warning(f"Impossible de mettre à jour {nom_champ}: {e}") except Exception as e: logger.error(f"Erreur MAJ champ libre: {e}") return False def _lire_client_obj(self, code_client): """Retourne l'objet client Sage brut (pour remises)""" if not self.cial: return None try: with self._com_context(), self._lock_com: factory = self.cial.CptaApplication.FactoryClient persist = factory.ReadNumero(code_client) if persist: return self._cast_client(persist) except: pass return None def lire_contact_principal_client(self, code_client): if not self.cial: return None try: with self._com_context(), self._lock_com: factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero(code_client) if not persist_client: return None client = self._cast_client(persist_client) if not client: return None # Récupérer infos contact principal contact_info = { "client_code": code_client, "client_intitule": getattr(client, "CT_Intitule", ""), "email": None, "nom": None, "telephone": None, } # Email principal depuis Telecom try: telecom = getattr(client, "Telecom", None) if telecom: contact_info["email"] = getattr(telecom, "EMail", "") contact_info["telephone"] = getattr(telecom, "Telephone", "") except: pass # Nom du contact try: contact_info["nom"] = ( getattr(client, "CT_Contact", "") or contact_info["client_intitule"] ) except: contact_info["nom"] = contact_info["client_intitule"] return contact_info except Exception as e: logger.error(f"Erreur lecture contact client {code_client}: {e}") return None def mettre_a_jour_derniere_relance(self, doc_id, type_doc): date_relance = datetime.now().strftime("%Y-%m-%d %H:%M:%S") return self.mettre_a_jour_champ_libre( doc_id, type_doc, "DerniereRelance", date_relance ) def lister_tous_prospects(self, filtre=""): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CT_Num, CT_Intitule, CT_Adresse, CT_Ville, CT_CodePostal, CT_Telephone, CT_EMail FROM F_COMPTET WHERE CT_Type = 0 AND CT_Prospect = 1 """ params = [] if filtre: query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() prospects = [] for row in rows: prospects.append( { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "adresse": self._safe_strip(row.CT_Adresse), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "telephone": self._safe_strip(row.CT_Telephone), "email": self._safe_strip(row.CT_EMail), "type": 0, "est_prospect": True, } ) logger.info(f"✅ SQL: {len(prospects)} prospects") return prospects except Exception as e: logger.error(f"❌ Erreur SQL prospects: {e}") return [] def lire_prospect(self, code_prospect): try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT CT_Num, CT_Intitule, CT_Type, CT_Qualite, CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays, CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie, CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect, CT_Contact, CT_FormeJuridique, CT_Secteur FROM F_COMPTET WHERE CT_Num = ? AND CT_Type = 0 AND CT_Prospect = 1 """, (code_prospect.upper(),), ) row = cursor.fetchone() if not row: return None return { "numero": self._safe_strip(row.CT_Num), "intitule": self._safe_strip(row.CT_Intitule), "type": 0, "qualite": self._safe_strip(row.CT_Qualite), "est_prospect": True, "adresse": self._safe_strip(row.CT_Adresse), "complement": self._safe_strip(row.CT_Complement), "ville": self._safe_strip(row.CT_Ville), "code_postal": self._safe_strip(row.CT_CodePostal), "pays": self._safe_strip(row.CT_Pays), "telephone": self._safe_strip(row.CT_Telephone), "portable": self._safe_strip(row.CT_Portable), "email": self._safe_strip(row.CT_EMail), "telecopie": self._safe_strip(row.CT_Telecopie), "siret": self._safe_strip(row.CT_Siret), "tva_intra": self._safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "contact": self._safe_strip(row.CT_Contact), "forme_juridique": self._safe_strip(row.CT_FormeJuridique), "secteur": self._safe_strip(row.CT_Secteur), } except Exception as e: logger.error(f"❌ Erreur SQL prospect {code_prospect}: {e}") return None def lister_avoirs(self, limit=100, statut=None): try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = f""" SELECT TOP ({limit}) d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, d.DO_Statut, d.CT_Num, c.CT_Intitule FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num WHERE d.DO_Type = 50 """ params = [] if statut is not None: query += " AND d.DO_Statut = ?" params.append(statut) query += " ORDER BY d.DO_Date DESC" cursor.execute(query, params) rows = cursor.fetchall() avoirs = [] for row in rows: avoirs.append( { "numero": self._safe_strip(row.DO_Piece), "reference": self._safe_strip(row.DO_Ref), "date": str(row.DO_Date) if row.DO_Date else "", "client_code": self._safe_strip(row.CT_Num), "client_intitule": self._safe_strip(row.CT_Intitule), "total_ht": ( float(row.DO_TotalHT) if row.DO_TotalHT else 0.0 ), "total_ttc": ( float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0 ), "statut": row.DO_Statut if row.DO_Statut is not None else 0, } ) return avoirs except Exception as e: logger.error(f"❌ Erreur SQL avoirs: {e}") return [] def lire_avoir(self, numero): return self._lire_document_sql(numero, type_doc=50) def lister_livraisons(self, limit=100, statut=None): """📖 Liste les livraisons via SQL (méthode legacy)""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = f""" SELECT TOP ({limit}) d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, d.DO_Statut, d.CT_Num, c.CT_Intitule FROM F_DOCENTETE d LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num WHERE d.DO_Type = 30 """ params = [] if statut is not None: query += " AND d.DO_Statut = ?" params.append(statut) query += " ORDER BY d.DO_Date DESC" cursor.execute(query, params) rows = cursor.fetchall() livraisons = [] for row in rows: livraisons.append( { "numero": self._safe_strip(row.DO_Piece), "reference": self._safe_strip(row.DO_Ref), "date": str(row.DO_Date) if row.DO_Date else "", "client_code": self._safe_strip(row.CT_Num), "client_intitule": self._safe_strip(row.CT_Intitule), "total_ht": ( float(row.DO_TotalHT) if row.DO_TotalHT else 0.0 ), "total_ttc": ( float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0 ), "statut": row.DO_Statut if row.DO_Statut is not None else 0, } ) return livraisons except Exception as e: logger.error(f"❌ Erreur SQL livraisons: {e}") return [] def lire_livraison(self, numero): """📖 Lit UNE livraison via SQL (avec lignes)""" return self._lire_document_sql(numero, type_doc=30) def creer_client(self, client_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 0 : VALIDATION & NETTOYAGE # ======================================== logger.info("🔍 === VALIDATION DES DONNÉES ===") if not client_data.get("intitule"): raise ValueError("Le champ 'intitule' est obligatoire") # Nettoyage et troncature intitule = str(client_data["intitule"])[:69].strip() num_prop = ( str(client_data.get("num", "")).upper()[:17].strip() if client_data.get("num") else "" ) compte = str(client_data.get("compte_collectif", "411000"))[:13].strip() adresse = str(client_data.get("adresse", ""))[:35].strip() code_postal = str(client_data.get("code_postal", ""))[:9].strip() ville = str(client_data.get("ville", ""))[:35].strip() pays = str(client_data.get("pays", ""))[:35].strip() telephone = str(client_data.get("telephone", ""))[:21].strip() email = str(client_data.get("email", ""))[:69].strip() siret = str(client_data.get("siret", ""))[:14].strip() tva_intra = str(client_data.get("tva_intra", ""))[:25].strip() logger.info(f" intitule: '{intitule}' (len={len(intitule)})") logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})") logger.info(f" compte: '{compte}' (len={len(compte)})") # ======================================== # ÉTAPE 1 : CRÉATION OBJET CLIENT # ======================================== factory_client = self.cial.CptaApplication.FactoryClient persist = factory_client.Create() client = win32com.client.CastTo(persist, "IBOClient3") # 🔑 CRITIQUE : Initialiser l'objet client.SetDefault() logger.info("✅ Objet client créé et initialisé") # ======================================== # ÉTAPE 2 : CHAMPS OBLIGATOIRES (dans l'ordre !) # ======================================== logger.info("📝 Définition des champs obligatoires...") # 1. Intitulé (OBLIGATOIRE) client.CT_Intitule = intitule logger.debug(f" ✅ CT_Intitule: '{intitule}'") # ❌ CT_Type SUPPRIMÉ (n'existe pas dans cette version) # client.CT_Type = 0 # <-- LIGNE SUPPRIMÉE # 2. Qualité (important pour filtrage Client/Fournisseur) try: client.CT_Qualite = "CLI" logger.debug(" ✅ CT_Qualite: 'CLI'") except: logger.debug(" ⚠️ CT_Qualite non défini (pas critique)") # 3. Compte général principal (OBLIGATOIRE) try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() # Assigner l'objet CompteG client.CompteGPrinc = compte_obj logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné") else: logger.warning( f" ⚠️ Compte {compte} introuvable - utilisation du compte par défaut" ) except Exception as e: logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}") # 4. Numéro client (OBLIGATOIRE - générer si vide) if num_prop: client.CT_Num = num_prop logger.debug(f" ✅ CT_Num fourni: '{num_prop}'") else: # 🔑 CRITIQUE : Générer le numéro automatiquement try: # Méthode 1 : Utiliser SetDefaultNumPiece (si disponible) if hasattr(client, "SetDefaultNumPiece"): client.SetDefaultNumPiece() num_genere = getattr(client, "CT_Num", "") logger.debug( f" ✅ CT_Num auto-généré (SetDefaultNumPiece): '{num_genere}'" ) else: # Méthode 2 : Lire le prochain numéro depuis la souche factory_client = self.cial.CptaApplication.FactoryClient num_genere = factory_client.GetNextNumero() if num_genere: client.CT_Num = num_genere logger.debug( f" ✅ CT_Num auto-généré (GetNextNumero): '{num_genere}'" ) else: # Méthode 3 : Fallback - utiliser un timestamp import time num_genere = f"CLI{int(time.time()) % 1000000}" client.CT_Num = num_genere logger.warning( f" ⚠️ CT_Num fallback temporaire: '{num_genere}'" ) except Exception as e: logger.error(f" ❌ Impossible de générer CT_Num: {e}") raise ValueError( "Impossible de générer le numéro client automatiquement. Veuillez fournir un numéro manuellement." ) # 5. Catégories tarifaires (valeurs par défaut) try: # Catégorie tarifaire (obligatoire) if hasattr(client, "N_CatTarif"): client.N_CatTarif = 1 # Catégorie comptable (obligatoire) if hasattr(client, "N_CatCompta"): client.N_CatCompta = 1 # Autres catégories if hasattr(client, "N_Period"): client.N_Period = 1 if hasattr(client, "N_Expedition"): client.N_Expedition = 1 if hasattr(client, "N_Condition"): client.N_Condition = 1 if hasattr(client, "N_Risque"): client.N_Risque = 1 logger.debug(" ✅ Catégories (N_*) initialisées") except Exception as e: logger.warning(f" ⚠️ Catégories: {e}") # ======================================== # ÉTAPE 3 : CHAMPS OPTIONNELS MAIS RECOMMANDÉS # ======================================== logger.info("📝 Définition champs optionnels...") # Adresse (objet IAdresse) if any([adresse, code_postal, ville, pays]): try: adresse_obj = client.Adresse if adresse: adresse_obj.Adresse = adresse if code_postal: adresse_obj.CodePostal = code_postal if ville: adresse_obj.Ville = ville if pays: adresse_obj.Pays = pays logger.debug(" ✅ Adresse définie") except Exception as e: logger.warning(f" ⚠️ Adresse: {e}") # Télécom (objet ITelecom) if telephone or email: try: telecom_obj = client.Telecom if telephone: telecom_obj.Telephone = telephone if email: telecom_obj.EMail = email logger.debug(" ✅ Télécom défini") except Exception as e: logger.warning(f" ⚠️ Télécom: {e}") # Identifiants fiscaux if siret: try: client.CT_Siret = siret logger.debug(f" ✅ SIRET: '{siret}'") except Exception as e: logger.warning(f" ⚠️ SIRET: {e}") if tva_intra: try: client.CT_Identifiant = tva_intra logger.debug(f" ✅ TVA intracommunautaire: '{tva_intra}'") except Exception as e: logger.warning(f" ⚠️ TVA: {e}") # Autres champs utiles (valeurs par défaut intelligentes) try: # Type de facturation (1 = facture normale) if hasattr(client, "CT_Facture"): client.CT_Facture = 1 # Lettrage automatique activé if hasattr(client, "CT_Lettrage"): client.CT_Lettrage = True # Pas de prospect if hasattr(client, "CT_Prospect"): client.CT_Prospect = False # Client actif (pas en sommeil) if hasattr(client, "CT_Sommeil"): client.CT_Sommeil = False logger.debug(" ✅ Options par défaut définies") except Exception as e: logger.debug(f" ⚠️ Options: {e}") # ======================================== # ÉTAPE 4 : DIAGNOSTIC PRÉ-WRITE (pour debug) # ======================================== logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===") champs_critiques = [ ("CT_Intitule", "str"), ("CT_Num", "str"), ("CompteGPrinc", "object"), ("N_CatTarif", "int"), ("N_CatCompta", "int"), ] for champ, type_attendu in champs_critiques: try: val = getattr(client, champ, None) if type_attendu == "object": status = "✅ Objet défini" if val else "❌ NULL" else: if type_attendu == "str": status = ( f"✅ '{val}' (len={len(val)})" if val else "❌ Vide" ) else: status = f"✅ {val}" logger.info(f" {champ}: {status}") except Exception as e: logger.error(f" {champ}: ❌ Erreur - {e}") # ======================================== # ÉTAPE 5 : VÉRIFICATION FINALE CT_Num # ======================================== num_avant_write = getattr(client, "CT_Num", "") if not num_avant_write: logger.error( "❌ CRITIQUE: CT_Num toujours vide après toutes les tentatives !" ) raise ValueError( "Le numéro client (CT_Num) est obligatoire mais n'a pas pu être généré. " "Veuillez fournir un numéro manuellement via le paramètre 'num'." ) logger.info(f"✅ CT_Num confirmé avant Write(): '{num_avant_write}'") # ======================================== # ÉTAPE 6 : ÉCRITURE EN BASE # ======================================== logger.info("💾 Écriture du client dans Sage...") try: client.Write() logger.info("✅ Write() réussi !") except Exception as e: error_detail = str(e) # Récupérer l'erreur Sage détaillée try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = ( f"{sage_error.Description} (Code: {sage_error.Number})" ) logger.error(f"❌ Erreur Sage: {error_detail}") except: pass # Analyser l'erreur spécifique if "longueur invalide" in error_detail.lower(): logger.error("❌ ERREUR 'longueur invalide' - Dump des champs:") for attr in dir(client): if attr.startswith("CT_") or attr.startswith("N_"): try: val = getattr(client, attr, None) if isinstance(val, str): logger.error( f" {attr}: '{val}' (len={len(val)})" ) elif val is not None and not callable(val): logger.error( f" {attr}: {val} (type={type(val).__name__})" ) except: pass if ( "doublon" in error_detail.lower() or "existe" in error_detail.lower() ): raise ValueError(f"Ce client existe déjà : {error_detail}") raise RuntimeError(f"Échec Write(): {error_detail}") # ======================================== # ÉTAPE 7 : RELECTURE & FINALISATION # ======================================== try: client.Read() except Exception as e: logger.warning(f"⚠️ Impossible de relire: {e}") num_final = getattr(client, "CT_Num", "") if not num_final: raise RuntimeError("CT_Num vide après Write()") logger.info(f"✅✅✅ CLIENT CRÉÉ: {num_final} - {intitule} ✅✅✅") # ======================================== # ÉTAPE 8 : REFRESH CACHE # ======================================== return { "numero": num_final, "intitule": intitule, "compte_collectif": compte, "type": 0, # Par défaut client "adresse": adresse or None, "code_postal": code_postal or None, "ville": ville or None, "pays": pays or None, "email": email or None, "telephone": telephone or None, "siret": siret or None, "tva_intra": tva_intra or None, } except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur création client: {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_client(self, code: str, client_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ======================================== # ÉTAPE 1 : CHARGER LE CLIENT EXISTANT # ======================================== logger.info(f"🔍 Recherche client {code}...") factory_client = self.cial.CptaApplication.FactoryClient persist = factory_client.ReadNumero(code) if not persist: raise ValueError(f"Client {code} introuvable") client = win32com.client.CastTo(persist, "IBOClient3") client.Read() logger.info( f"✅ Client {code} trouvé: {getattr(client, 'CT_Intitule', '')}" ) # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS # ======================================== logger.info("📝 Mise à jour des champs...") champs_modifies = [] # Intitulé if "intitule" in client_data: intitule = str(client_data["intitule"])[:69].strip() client.CT_Intitule = intitule champs_modifies.append(f"intitule='{intitule}'") # Adresse if any( k in client_data for k in ["adresse", "code_postal", "ville", "pays"] ): try: adresse_obj = client.Adresse if "adresse" in client_data: adresse = str(client_data["adresse"])[:35].strip() adresse_obj.Adresse = adresse champs_modifies.append("adresse") if "code_postal" in client_data: cp = str(client_data["code_postal"])[:9].strip() adresse_obj.CodePostal = cp champs_modifies.append("code_postal") if "ville" in client_data: ville = str(client_data["ville"])[:35].strip() adresse_obj.Ville = ville champs_modifies.append("ville") if "pays" in client_data: pays = str(client_data["pays"])[:35].strip() adresse_obj.Pays = pays champs_modifies.append("pays") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour adresse: {e}") # Télécom if "email" in client_data or "telephone" in client_data: try: telecom_obj = client.Telecom if "email" in client_data: email = str(client_data["email"])[:69].strip() telecom_obj.EMail = email champs_modifies.append("email") if "telephone" in client_data: tel = str(client_data["telephone"])[:21].strip() telecom_obj.Telephone = tel champs_modifies.append("telephone") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour télécom: {e}") # SIRET if "siret" in client_data: try: siret = str(client_data["siret"])[:14].strip() client.CT_Siret = siret champs_modifies.append("siret") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}") # TVA Intracommunautaire if "tva_intra" in client_data: try: tva = str(client_data["tva_intra"])[:25].strip() client.CT_Identifiant = tva champs_modifies.append("tva_intra") except Exception as e: logger.warning(f"⚠️ Erreur mise à jour TVA: {e}") if not champs_modifies: logger.warning("⚠️ Aucun champ à modifier") return self._extraire_client(client) logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}") # ======================================== # ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS # ======================================== logger.info("💾 Écriture des modifications...") try: client.Write() logger.info("✅ Write() réussi !") except Exception as e: error_detail = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = ( f"{sage_error.Description} (Code: {sage_error.Number})" ) except: pass logger.error(f"❌ Erreur Write(): {error_detail}") raise RuntimeError(f"Échec modification: {error_detail}") # ======================================== # ÉTAPE 4 : RELIRE ET RETOURNER # ======================================== client.Read() logger.info( f"✅✅✅ CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅" ) # Refresh cache return self._extraire_client(client) except ValueError as e: logger.error(f"❌ Erreur métier: {e}") raise except Exception as e: logger.error(f"❌ Erreur modification client: {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: # ÉTAPE 1 : CHARGER LE DEVIS logger.info(f"🔍 Recherche devis {numero}...") factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(0, numero) if not persist: index = 1 while index < 10000: try: persist_test = factory.List(index) if persist_test is None: break doc_test = win32com.client.CastTo( persist_test, "IBODocumentVente3" ) doc_test.Read() if ( getattr(doc_test, "DO_Type", -1) == 0 and getattr(doc_test, "DO_Piece", "") == numero ): persist = persist_test break index += 1 except: index += 1 if not persist: raise ValueError(f"Devis {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() logger.info(f"✅ Devis {numero} trouvé") # Vérifier transformation verification = self.verifier_si_deja_transforme_sql(numero, 0) if verification["deja_transforme"]: docs_cibles = verification["documents_cibles"] nums = [d["numero"] for d in docs_cibles] raise ValueError( f"❌ Devis {numero} déjà transformé en {len(docs_cibles)} document(s): {', '.join(nums)}" ) statut_actuel = getattr(doc, "DO_Statut", 0) if statut_actuel == 5: raise ValueError(f"Devis {numero} déjà transformé (statut=5)") # ÉTAPE 2 : CHAMPS SIMPLES champs_modifies = [] if "date_devis" in devis_data: import pywintypes date_str = devis_data["date_devis"] date_obj = ( datetime.fromisoformat(date_str) if isinstance(date_str, str) else date_str ) doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f"📅 Date: {date_obj.date()}") if "statut" in devis_data: nouveau_statut = devis_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f"📊 Statut: {statut_actuel} → {nouveau_statut}") if champs_modifies: doc.Write() # ÉTAPE 3 : MODIFICATION INTELLIGENTE DES LIGNES if "lignes" in devis_data and devis_data["lignes"] is not None: logger.info(f"🔄 Modification intelligente des lignes...") nouvelles_lignes = devis_data["lignes"] nb_nouvelles = len(nouvelles_lignes) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # Compter existantes nb_existantes = 0 index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_existantes += 1 index += 1 except: break logger.info( f"📊 {nb_existantes} existantes → {nb_nouvelles} nouvelles" ) # MODIFIER EXISTANTES nb_a_modifier = min(nb_existantes, nb_nouvelles) for idx in range(1, nb_a_modifier + 1): ligne_data = nouvelles_lignes[idx - 1] ligne_p = factory_lignes.List(idx) ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"Article {ligne_data['article_code']} introuvable" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() try: ligne.WriteDefault() except: pass quantite = float(ligne_data["quantite"]) try: ligne.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne.SetDefaultArticle(article_obj, quantite) except: ligne.DL_Design = ligne_data.get("designation", "") ligne.DL_Qte = quantite if ligne_data.get("prix_unitaire_ht"): ligne.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne.DL_Remise01REM_Valeur = float( ligne_data["remise_pourcentage"] ) ligne.DL_Remise01REM_Type = 0 except: pass ligne.Write() logger.debug(f" ✅ Ligne {idx} modifiée") # AJOUTER MANQUANTES if nb_nouvelles > nb_existantes: for idx in range(nb_existantes, nb_nouvelles): ligne_data = nouvelles_lignes[idx] persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"Article {ligne_data['article_code']} introuvable" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get( "designation", "" ) ligne_obj.DL_Qte = quantite if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne_obj.DL_Remise01REM_Valeur = float( ligne_data["remise_pourcentage"] ) ligne_obj.DL_Remise01REM_Type = 0 except: pass ligne_obj.Write() logger.debug(f" ✅ Ligne {idx + 1} ajoutée") # SUPPRIMER EN TROP elif nb_nouvelles < nb_existantes: for idx in range(nb_existantes, nb_nouvelles, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() try: ligne.Remove() except AttributeError: ligne.WriteDefault() except: pass except: pass champs_modifies.append("lignes") # VALIDATION logger.info("💾 Validation finale...") doc.Write() import time time.sleep(1) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ✅✅✅") logger.info(f"💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0), } 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 technique Sage: {str(e)}") def creer_commande_enrichi(self, commande_data: dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création commande pour client {commande_data['client']['code']}" ) try: with self._com_context(), self._lock_com: transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("✅ Transaction Sage démarrée") except: pass try: # Création document COMMANDE (type 10) process = self.cial.CreateProcess_Document( settings.SAGE_TYPE_BON_COMMANDE ) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document commande créé") # Date import pywintypes if isinstance(commande_data["date_commande"], str): date_obj = datetime.fromisoformat( commande_data["date_commande"] ) elif isinstance(commande_data["date_commande"], date): date_obj = datetime.combine( commande_data["date_commande"], datetime.min.time() ) else: date_obj = datetime.now() doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero( commande_data["client"]["code"] ) if not persist_client: raise ValueError( f"Client {commande_data['client']['code']} introuvable" ) client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {commande_data['client']['code']} associé") # Référence externe (optionnelle) if commande_data.get("reference"): try: doc.DO_Ref = commande_data["reference"] logger.info(f"📖 Référence: {commande_data['reference']}") except: pass # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle logger.info(f"📦 Ajout de {len(commande_data['lignes'])} lignes...") for idx, ligne_data in enumerate(commande_data["lignes"], 1): logger.info( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # 📍 ÉTAPE 1: Charger l'article RÉEL depuis Sage persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"❌ Article {ligne_data['article_code']} introuvable dans Sage" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # 💰 ÉTAPE 2: Récupérer le prix de vente RÉEL prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) designation_sage = getattr(article_obj, "AR_Design", "") logger.info(f"💰 Prix Sage: {prix_sage}€") # ✅ TOLÉRER prix = 0 (articles de service, etc.) if prix_sage == 0: logger.warning( f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # 📍 ÉTAPE 3: Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) # ✅ SetDefaultArticleReference quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # ⚙️ ÉTAPE 4: Vérifier le prix automatique prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) logger.info(f"💰 Prix auto chargé: {prix_auto}€") # 💵 ÉTAPE 5: Ajuster le prix si nécessaire prix_a_utiliser = ligne_data.get("prix_unitaire_ht") if prix_a_utiliser is not None and prix_a_utiliser > 0: # Prix personnalisé fourni ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") elif prix_auto == 0 and prix_sage > 0: # Pas de prix auto mais prix Sage existe ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: # Prix auto OK logger.info(f"💰 Prix auto conservé: {prix_auto}€") # ✅ SINON: Prix reste à 0 (toléré pour services, etc.) prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) montant_ligne = quantite * prix_final logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€") # 🎁 Remise remise = ligne_data.get("remise_pourcentage", 0) if remise > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 montant_apres_remise = montant_ligne * ( 1 - remise / 100 ) logger.info( f"🎁 Remise {remise}% → {montant_apres_remise}€" ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") # 💾 ÉTAPE 6: Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") # 🔍 VÉRIFICATION try: ligne_obj.Read() prix_enregistre = float( getattr(ligne_obj, "DL_PrixUnitaire", 0.0) ) montant_enregistre = float( getattr(ligne_obj, "DL_MontantHT", 0.0) ) logger.info( f"🔍 Vérif: Prix={prix_enregistre}€, Montant HT={montant_enregistre}€" ) except Exception as e: logger.warning(f"⚠️ Impossible de vérifier: {e}") # Validation doc.Write() process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() # Récupération numéro time.sleep(2) numero_commande = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_commande = getattr(doc_result, "DO_Piece", "") except: pass if not numero_commande: numero_commande = getattr(doc, "DO_Piece", "") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece( settings.SAGE_TYPE_BON_COMMANDE, numero_commande ) if persist_reread: doc_final = win32com.client.CastTo( persist_reread, "IBODocumentVente3" ) doc_final.Read() total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 logger.info( f"✅✅✅ COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC ✅✅✅" ) return { "numero_commande": numero_commande, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(commande_data["lignes"]), "client_code": commande_data["client"]["code"], "date_commande": str(date_obj.date()), } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass raise except Exception as e: logger.error(f"❌ Erreur création commande: {e}", exc_info=True) raise RuntimeError(f"Échec création commande: {str(e)}") def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION COMMANDE {numero} ===") # ======================================== # ÉTAPE 1 : CHARGER LE DOCUMENT # ======================================== logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [10, 3, settings.SAGE_TYPE_BON_COMMANDE]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Commande {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) type_reel = getattr(doc, "DO_Type", -1) logger.info(f" 📊 Type={type_reel}, Statut={statut_actuel}") # ======================================== # ÉTAPE 2 : VÉRIFIER CLIENT INITIAL # ======================================== client_code_initial = "" try: client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_code_initial = getattr(client_obj, "CT_Num", "").strip() logger.info(f" 👤 Client initial: {client_code_initial}") else: logger.error(" ❌ Objet Client NULL à l'état initial !") except Exception as e: logger.error(f" ❌ Erreur lecture client initial: {e}") if not client_code_initial: raise ValueError("❌ Client introuvable dans le document") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ======================================== # ÉTAPE 3 : DÉTERMINER LES MODIFICATIONS # ======================================== champs_modifies = [] modif_date = "date_commande" in commande_data modif_statut = "statut" in commande_data modif_ref = "reference" in commande_data modif_lignes = ( "lignes" in commande_data and commande_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ======================================== # ÉTAPE 4 : TEST WRITE() BASIQUE # ======================================== logger.info("🧪 Test Write() basique (sans modification)...") try: doc.Write() logger.info(" ✅ Write() basique OK") doc.Read() # Vérifier que le client est toujours là client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_apres = getattr(client_obj, "CT_Num", "") if client_apres == client_code_initial: logger.info(f" ✅ Client préservé: {client_apres}") else: logger.error( f" ❌ Client a changé: {client_code_initial} → {client_apres}" ) else: logger.error(" ❌ Client devenu NULL après Write() basique") except Exception as e: logger.error(f" ❌ Write() basique ÉCHOUE: {e}") logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ") raise ValueError( f"Document verrouillé, impossible de modifier: {e}" ) # ======================================== # ÉTAPE 5 : MODIFICATIONS SIMPLES (pas de lignes) # ======================================== if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: logger.info(" 📅 Modification date...") import pywintypes date_str = commande_data["date_commande"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: logger.info(" 📊 Modification statut...") nouveau_statut = commande_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: logger.info(" 📖 Modification référence...") try: doc.DO_Ref = commande_data["reference"] champs_modifies.append("reference") logger.info( f" ✅ Référence définie: {commande_data['reference']}" ) except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") # Écrire sans réassocier le client logger.info(" 💾 Write() sans réassociation client...") try: doc.Write() logger.info(" ✅ Write() réussi") doc.Read() # Vérifier client client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_apres = getattr(client_obj, "CT_Num", "") if client_apres == client_code_initial: logger.info(f" ✅ Client préservé: {client_apres}") else: logger.error( f" ❌ Client perdu: {client_code_initial} → {client_apres}" ) except Exception as e: error_msg = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_msg = f"{sage_error.Description} (Code: {sage_error.Number})" except: pass logger.error(f" ❌ Write() échoue: {error_msg}") raise ValueError(f"Sage refuse: {error_msg}") # ======================================== # ÉTAPE 6 : REMPLACEMENT COMPLET DES LIGNES # ======================================== elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = commande_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles lignes" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # ============================================ # SOUS-ÉTAPE 1 : SUPPRIMER TOUTES LES LIGNES EXISTANTES # ============================================ if nb_lignes_initial > 0: logger.info( f" 🗑️ Suppression de {nb_lignes_initial} lignes existantes..." ) # Supprimer depuis la fin pour éviter les problèmes d'index for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() # ✅ Utiliser .Remove() comme indiqué ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Impossible de supprimer ligne {idx}: {e}" ) # Continuer même si une suppression échoue logger.info(" ✅ Toutes les lignes existantes supprimées") # ============================================ # SOUS-ÉTAPE 2 : AJOUTER LES NOUVELLES LIGNES # ============================================ logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): logger.info( f" --- Ligne {idx}/{nb_nouvelles}: {ligne_data['article_code']} ---" ) # Charger l'article persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"Article {ligne_data['article_code']} introuvable" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # Créer nouvelle ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) # Associer article try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite # Prix if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) # Remise if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne_obj.DL_Remise01REM_Valeur = float( ligne_data["remise_pourcentage"] ) ligne_obj.DL_Remise01REM_Type = 0 except: pass # Écrire la ligne ligne_obj.Write() logger.info(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") # Écrire le document logger.info(" 💾 Write() document après remplacement lignes...") doc.Write() logger.info(" ✅ Document écrit") doc.Read() # Vérifier client client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_apres = getattr(client_obj, "CT_Num", "") logger.info(f" 👤 Client après remplacement: {client_apres}") else: logger.error(" ❌ Client NULL après remplacement") champs_modifies.append("lignes") # ======================================== # ÉTAPE 7 : RELECTURE ET RETOUR # ======================================== logger.info("📊 Relecture finale...") import time time.sleep(1) doc.Read() # Vérifier client final client_obj_final = getattr(doc, "Client", None) if client_obj_final: client_obj_final.Read() client_final = getattr(client_obj_final, "CT_Num", "") else: client_final = "" total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅ ✅ ✅ SUCCÈS: {numero} modifiée ✅ ✅ ✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") logger.info(f" 👤 Client final: {client_final}") logger.info(f" 📝 Champs modifiés: {champs_modifies}") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0), "client_code": client_final, } 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) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = ( f"Erreur Sage: {err.Description} (Code: {err.Number})" ) except: pass raise RuntimeError(f"Erreur Sage: {error_message}") def creer_livraison_enrichi(self, livraison_data: dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création livraison pour client {livraison_data['client']['code']}" ) try: with self._com_context(), self._lock_com: transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("✅ Transaction Sage démarrée") except: pass try: # Création document LIVRAISON (type 30) process = self.cial.CreateProcess_Document( settings.SAGE_TYPE_BON_LIVRAISON ) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document livraison créé") # Date import pywintypes if isinstance(livraison_data["date_livraison"], str): date_obj = datetime.fromisoformat( livraison_data["date_livraison"] ) elif isinstance(livraison_data["date_livraison"], date): date_obj = datetime.combine( livraison_data["date_livraison"], datetime.min.time() ) else: date_obj = datetime.now() doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero( livraison_data["client"]["code"] ) if not persist_client: raise ValueError( f"Client {livraison_data['client']['code']} introuvable" ) client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {livraison_data['client']['code']} associé") # Référence externe (optionnelle) if livraison_data.get("reference"): try: doc.DO_Ref = livraison_data["reference"] logger.info(f"📖 Référence: {livraison_data['reference']}") except: pass # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle logger.info( f"📦 Ajout de {len(livraison_data['lignes'])} lignes..." ) for idx, ligne_data in enumerate(livraison_data["lignes"], 1): logger.info( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # Charger l'article RÉEL depuis Sage persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"❌ Article {ligne_data['article_code']} introuvable dans Sage" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # Récupérer le prix de vente RÉEL prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) designation_sage = getattr(article_obj, "AR_Design", "") logger.info(f"💰 Prix Sage: {prix_sage}€") if prix_sage == 0: logger.warning( f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # Vérifier le prix automatique prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) logger.info(f"💰 Prix auto chargé: {prix_auto}€") # Ajuster le prix si nécessaire prix_a_utiliser = ligne_data.get("prix_unitaire_ht") if prix_a_utiliser is not None and prix_a_utiliser > 0: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") elif prix_auto == 0 and prix_sage > 0: ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: logger.info(f"💰 Prix auto conservé: {prix_auto}€") prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) montant_ligne = quantite * prix_final logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€") # Remise remise = ligne_data.get("remise_pourcentage", 0) if remise > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 montant_apres_remise = montant_ligne * ( 1 - remise / 100 ) logger.info( f"🎁 Remise {remise}% → {montant_apres_remise}€" ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") # Validation doc.Write() process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() # Récupération numéro time.sleep(2) numero_livraison = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_livraison = getattr(doc_result, "DO_Piece", "") except: pass if not numero_livraison: numero_livraison = getattr(doc, "DO_Piece", "") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece( settings.SAGE_TYPE_BON_LIVRAISON, numero_livraison ) if persist_reread: doc_final = win32com.client.CastTo( persist_reread, "IBODocumentVente3" ) doc_final.Read() total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 logger.info( f"✅✅✅ LIVRAISON CRÉÉE: {numero_livraison} - {total_ttc}€ TTC ✅✅✅" ) return { "numero_livraison": numero_livraison, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(livraison_data["lignes"]), "client_code": livraison_data["client"]["code"], "date_livraison": str(date_obj.date()), } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass raise except Exception as e: logger.error(f"❌ Erreur création livraison: {e}", exc_info=True) raise RuntimeError(f"Échec création livraison: {str(e)}") def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION LIVRAISON {numero} ===") # ======================================== # ÉTAPE 1 : CHARGER LE DOCUMENT # ======================================== logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [30, settings.SAGE_TYPE_BON_LIVRAISON]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Livraison {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) logger.info(f" 📊 Statut={statut_actuel}") # Vérifier qu'elle n'est pas transformée if statut_actuel == 5: raise ValueError(f"La livraison {numero} a déjà été transformée") if statut_actuel == 6: raise ValueError(f"La livraison {numero} est annulée") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ======================================== # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS # ======================================== champs_modifies = [] modif_date = "date_livraison" in livraison_data modif_statut = "statut" in livraison_data modif_ref = "reference" in livraison_data modif_lignes = ( "lignes" in livraison_data and livraison_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ======================================== # ÉTAPE 3 : MODIFICATIONS SIMPLES # ======================================== if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: import pywintypes date_str = livraison_data["date_livraison"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: nouveau_statut = livraison_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: try: doc.DO_Ref = livraison_data["reference"] champs_modifies.append("reference") logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") doc.Write() logger.info(" ✅ Write() réussi") # ======================================== # ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES # ======================================== elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = livraison_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Erreur suppression ligne {idx}: {e}" ) logger.info(" ✅ Toutes les lignes supprimées") # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"Article {ligne_data['article_code']} introuvable" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne_obj.DL_Remise01REM_Valeur = float( ligne_data["remise_pourcentage"] ) ligne_obj.DL_Remise01REM_Type = 0 except: pass ligne_obj.Write() logger.debug(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") doc.Write() champs_modifies.append("lignes") # ======================================== # ÉTAPE 5 : RELECTURE ET RETOUR # ======================================== import time time.sleep(1) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ LIVRAISON MODIFIÉE: {numero} ✅✅✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0), } 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)}") def creer_avoir_enrichi(self, avoir_data: dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création avoir pour client {avoir_data['client']['code']}" ) try: with self._com_context(), self._lock_com: transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("✅ Transaction Sage démarrée") except: pass try: # Création document AVOIR (type 50) process = self.cial.CreateProcess_Document( settings.SAGE_TYPE_BON_AVOIR ) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document avoir créé") # Date import pywintypes if isinstance(avoir_data["date_avoir"], str): date_obj = datetime.fromisoformat(avoir_data["date_avoir"]) elif isinstance(avoir_data["date_avoir"], date): date_obj = datetime.combine( avoir_data["date_avoir"], datetime.min.time() ) else: date_obj = datetime.now() doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero( avoir_data["client"]["code"] ) if not persist_client: raise ValueError( f"Client {avoir_data['client']['code']} introuvable" ) client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {avoir_data['client']['code']} associé") # Référence externe (optionnelle) if avoir_data.get("reference"): try: doc.DO_Ref = avoir_data["reference"] logger.info(f"📖 Référence: {avoir_data['reference']}") except: pass # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle logger.info(f"📦 Ajout de {len(avoir_data['lignes'])} lignes...") for idx, ligne_data in enumerate(avoir_data["lignes"], 1): logger.info( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # Charger l'article RÉEL depuis Sage persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"❌ Article {ligne_data['article_code']} introuvable dans Sage" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # Récupérer le prix de vente RÉEL prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) designation_sage = getattr(article_obj, "AR_Design", "") logger.info(f"💰 Prix Sage: {prix_sage}€") if prix_sage == 0: logger.warning( f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # Vérifier le prix automatique prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) logger.info(f"💰 Prix auto chargé: {prix_auto}€") # Ajuster le prix si nécessaire prix_a_utiliser = ligne_data.get("prix_unitaire_ht") if prix_a_utiliser is not None and prix_a_utiliser > 0: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") elif prix_auto == 0 and prix_sage > 0: ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: logger.info(f"💰 Prix auto conservé: {prix_auto}€") prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) montant_ligne = quantite * prix_final logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€") # Remise remise = ligne_data.get("remise_pourcentage", 0) if remise > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 montant_apres_remise = montant_ligne * ( 1 - remise / 100 ) logger.info( f"🎁 Remise {remise}% → {montant_apres_remise}€" ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") # Validation doc.Write() process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() # Récupération numéro time.sleep(2) numero_avoir = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_avoir = getattr(doc_result, "DO_Piece", "") except: pass if not numero_avoir: numero_avoir = getattr(doc, "DO_Piece", "") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece( settings.SAGE_TYPE_BON_AVOIR, numero_avoir ) if persist_reread: doc_final = win32com.client.CastTo( persist_reread, "IBODocumentVente3" ) doc_final.Read() total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 logger.info( f"✅✅✅ AVOIR CRÉÉ: {numero_avoir} - {total_ttc}€ TTC ✅✅✅" ) return { "numero_avoir": numero_avoir, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(avoir_data["lignes"]), "client_code": avoir_data["client"]["code"], "date_avoir": str(date_obj.date()), } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass raise except Exception as e: logger.error(f"❌ Erreur création avoir: {e}", exc_info=True) raise RuntimeError(f"Échec création avoir: {str(e)}") def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION AVOIR {numero} ===") # ÉTAPE 1 : CHARGER LE DOCUMENT logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [50, settings.SAGE_TYPE_BON_AVOIR]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Avoir {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) logger.info(f" 📊 Statut={statut_actuel}") # Vérifier qu'il n'est pas transformé if statut_actuel == 5: raise ValueError(f"L'avoir {numero} a déjà été transformé") if statut_actuel == 6: raise ValueError(f"L'avoir {numero} est annulé") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS champs_modifies = [] modif_date = "date_avoir" in avoir_data modif_statut = "statut" in avoir_data modif_ref = "reference" in avoir_data modif_lignes = ( "lignes" in avoir_data and avoir_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ÉTAPE 3 : MODIFICATIONS SIMPLES if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: import pywintypes date_str = avoir_data["date_avoir"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: nouveau_statut = avoir_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: try: doc.DO_Ref = avoir_data["reference"] champs_modifies.append("reference") logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") doc.Write() logger.info(" ✅ Write() réussi") # ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = avoir_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Erreur suppression ligne {idx}: {e}" ) logger.info(" ✅ Toutes les lignes supprimées") # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"Article {ligne_data['article_code']} introuvable" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne_obj.DL_Remise01REM_Valeur = float( ligne_data["remise_pourcentage"] ) ligne_obj.DL_Remise01REM_Type = 0 except: pass ligne_obj.Write() logger.debug(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") doc.Write() champs_modifies.append("lignes") # ÉTAPE 5 : RELECTURE ET RETOUR import time time.sleep(1) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ AVOIR MODIFIÉ: {numero} ✅✅✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0), } 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)}") 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)}") def creer_facture_enrichi(self, facture_data: dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info( f"🚀 Début création facture pour client {facture_data['client']['code']}" ) try: with self._com_context(), self._lock_com: transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("✅ Transaction Sage démarrée") except: pass try: # Création document FACTURE (type 60) process = self.cial.CreateProcess_Document( settings.SAGE_TYPE_FACTURE ) doc = process.Document try: doc = win32com.client.CastTo(doc, "IBODocumentVente3") except: pass logger.info("📄 Document facture créé") # Date import pywintypes if isinstance(facture_data["date_facture"], str): date_obj = datetime.fromisoformat(facture_data["date_facture"]) elif isinstance(facture_data["date_facture"], date): date_obj = datetime.combine( facture_data["date_facture"], datetime.min.time() ) else: date_obj = datetime.now() doc.DO_Date = pywintypes.Time(date_obj) logger.info(f"📅 Date définie: {date_obj.date()}") # Client (CRITIQUE) factory_client = self.cial.CptaApplication.FactoryClient persist_client = factory_client.ReadNumero( facture_data["client"]["code"] ) if not persist_client: raise ValueError( f"Client {facture_data['client']['code']} introuvable" ) client_obj = self._cast_client(persist_client) if not client_obj: raise ValueError(f"Impossible de charger le client") doc.SetDefaultClient(client_obj) doc.Write() logger.info(f"👤 Client {facture_data['client']['code']} associé") # Référence externe (optionnelle) if facture_data.get("reference"): try: doc.DO_Ref = facture_data["reference"] logger.info(f"📖 Référence: {facture_data['reference']}") except: pass # ============================================ # CHAMPS SPÉCIFIQUES FACTURES # ============================================ logger.info("⚙️ Configuration champs spécifiques factures...") # Code journal (si disponible) try: if hasattr(doc, "DO_CodeJournal"): # Essayer de récupérer le code journal par défaut try: param_societe = ( self.cial.CptaApplication.ParametreSociete ) journal_defaut = getattr( param_societe, "P_CodeJournalVte", "VTE" ) doc.DO_CodeJournal = journal_defaut logger.info(f" ✅ Code journal: {journal_defaut}") except: doc.DO_CodeJournal = "VTE" logger.info(" ✅ Code journal: VTE (défaut)") except Exception as e: logger.debug(f" ⚠️ Code journal: {e}") # Souche (si disponible) try: if hasattr(doc, "DO_Souche"): doc.DO_Souche = 0 logger.debug(" ✅ Souche: 0 (défaut)") except: pass # Régime (si disponible) try: if hasattr(doc, "DO_Regime"): doc.DO_Regime = 0 logger.debug(" ✅ Régime: 0 (défaut)") except: pass # Lignes try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle logger.info(f"📦 Ajout de {len(facture_data['lignes'])} lignes...") for idx, ligne_data in enumerate(facture_data["lignes"], 1): logger.info( f"--- Ligne {idx}: {ligne_data['article_code']} ---" ) # Charger l'article RÉEL depuis Sage persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"❌ Article {ligne_data['article_code']} introuvable dans Sage" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # Récupérer le prix de vente RÉEL prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0)) designation_sage = getattr(article_obj, "AR_Design", "") logger.info(f"💰 Prix Sage: {prix_sage}€") if prix_sage == 0: logger.warning( f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)" ) # Créer la ligne ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) logger.info( f"✅ Article associé via SetDefaultArticleReference" ) except Exception as e: logger.warning( f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet" ) try: ligne_obj.SetDefaultArticle(article_obj, quantite) logger.info(f"✅ Article associé via SetDefaultArticle") except Exception as e2: logger.error(f"❌ Toutes les méthodes ont échoué") ligne_obj.DL_Design = ( designation_sage or ligne_data.get("designation", "") ) ligne_obj.DL_Qte = quantite logger.warning("⚠️ Configuration manuelle appliquée") # Vérifier le prix automatique prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) logger.info(f"💰 Prix auto chargé: {prix_auto}€") # Ajuster le prix si nécessaire prix_a_utiliser = ligne_data.get("prix_unitaire_ht") if prix_a_utiliser is not None and prix_a_utiliser > 0: ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser) logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€") elif prix_auto == 0 and prix_sage > 0: ligne_obj.DL_PrixUnitaire = float(prix_sage) logger.info(f"💰 Prix Sage forcé: {prix_sage}€") elif prix_auto > 0: logger.info(f"💰 Prix auto conservé: {prix_auto}€") prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0)) montant_ligne = quantite * prix_final logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€") # Remise remise = ligne_data.get("remise_pourcentage", 0) if remise > 0: try: ligne_obj.DL_Remise01REM_Valeur = float(remise) ligne_obj.DL_Remise01REM_Type = 0 montant_apres_remise = montant_ligne * ( 1 - remise / 100 ) logger.info( f"🎁 Remise {remise}% → {montant_apres_remise}€" ) except Exception as e: logger.warning(f"⚠️ Remise non appliquée: {e}") # Écrire la ligne ligne_obj.Write() logger.info(f"✅ Ligne {idx} écrite") # ============================================ # VALIDATION FINALE # ============================================ logger.info("💾 Validation facture...") # Réassocier le client avant validation (critique pour factures) try: doc.SetClient(client_obj) logger.debug(" ✅ Client réassocié avant validation") except: try: doc.SetDefaultClient(client_obj) except: pass doc.Write() logger.info("🔄 Process()...") process.Process() if transaction_active: self.cial.CptaApplication.CommitTrans() logger.info("✅ Transaction committée") # Récupération numéro time.sleep(2) numero_facture = None try: doc_result = process.DocumentResult if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_facture = getattr(doc_result, "DO_Piece", "") except: pass if not numero_facture: numero_facture = getattr(doc, "DO_Piece", "") if not numero_facture: raise RuntimeError("Numéro facture vide après création") logger.info(f"📄 Numéro facture: {numero_facture}") # Relecture factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece( settings.SAGE_TYPE_FACTURE, numero_facture ) if persist_reread: doc_final = win32com.client.CastTo( persist_reread, "IBODocumentVente3" ) doc_final.Read() total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0)) else: total_ht = 0.0 total_ttc = 0.0 logger.info( f"✅✅✅ FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC ✅✅✅" ) return { "numero_facture": numero_facture, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": len(facture_data["lignes"]), "client_code": facture_data["client"]["code"], "date_facture": str(date_obj.date()), } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() logger.error("❌ Transaction annulée (rollback)") except: pass raise except Exception as e: logger.error(f"❌ Erreur création facture: {e}", exc_info=True) raise RuntimeError(f"Échec création facture: {str(e)}") def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"🔬 === MODIFICATION FACTURE {numero} ===") # ÉTAPE 1 : CHARGER LE DOCUMENT logger.info("📂 Chargement document...") factory = self.cial.FactoryDocumentVente persist = None # Chercher le document for type_test in [60, 5, settings.SAGE_TYPE_FACTURE]: try: persist_test = factory.ReadPiece(type_test, numero) if persist_test: persist = persist_test logger.info(f" ✅ Document trouvé (type={type_test})") break except: continue if not persist: raise ValueError(f"❌ Facture {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() statut_actuel = getattr(doc, "DO_Statut", 0) logger.info(f" 📊 Statut={statut_actuel}") # Vérifier qu'elle n'est pas transformée ou annulée if statut_actuel == 5: raise ValueError(f"La facture {numero} a déjà été transformée") if statut_actuel == 6: raise ValueError(f"La facture {numero} est annulée") # Vérifier client initial client_code_initial = "" try: client_obj = getattr(doc, "Client", None) if client_obj: client_obj.Read() client_code_initial = getattr(client_obj, "CT_Num", "").strip() logger.info(f" 👤 Client initial: {client_code_initial}") except Exception as e: logger.error(f" ❌ Erreur lecture client initial: {e}") if not client_code_initial: raise ValueError("❌ Client introuvable dans le document") # Compter les lignes initiales nb_lignes_initial = 0 try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentVenteLigne", None) index = 1 while index <= 100: try: ligne_p = factory_lignes.List(index) if ligne_p is None: break nb_lignes_initial += 1 index += 1 except: break logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}") except Exception as e: logger.warning(f" ⚠️ Erreur comptage lignes: {e}") # ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS champs_modifies = [] modif_date = "date_facture" in facture_data modif_statut = "statut" in facture_data modif_ref = "reference" in facture_data modif_lignes = ( "lignes" in facture_data and facture_data["lignes"] is not None ) logger.info(f"📋 Modifications demandées:") logger.info(f" Date: {modif_date}") logger.info(f" Statut: {modif_statut}") logger.info(f" Référence: {modif_ref}") logger.info(f" Lignes: {modif_lignes}") # ÉTAPE 3 : TEST WRITE() BASIQUE logger.info("🧪 Test Write() basique (sans modification)...") try: doc.Write() logger.info(" ✅ Write() basique OK") doc.Read() except Exception as e: logger.error(f" ❌ Write() basique ÉCHOUE: {e}") logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ") raise ValueError( f"Document verrouillé, impossible de modifier: {e}" ) # ÉTAPE 4 : MODIFICATIONS SIMPLES if not modif_lignes and (modif_date or modif_statut or modif_ref): logger.info("🎯 Modifications simples (sans lignes)...") if modif_date: import pywintypes date_str = facture_data["date_facture"] if isinstance(date_str, str): date_obj = datetime.fromisoformat(date_str) elif isinstance(date_str, date): date_obj = datetime.combine(date_str, datetime.min.time()) else: date_obj = date_str doc.DO_Date = pywintypes.Time(date_obj) champs_modifies.append("date") logger.info(f" ✅ Date définie: {date_obj.date()}") if modif_statut: nouveau_statut = facture_data["statut"] doc.DO_Statut = nouveau_statut champs_modifies.append("statut") logger.info(f" ✅ Statut défini: {nouveau_statut}") if modif_ref: try: doc.DO_Ref = facture_data["reference"] champs_modifies.append("reference") logger.info(f" ✅ Référence définie") except Exception as e: logger.warning(f" ⚠️ Référence non définie: {e}") doc.Write() logger.info(" ✅ Write() réussi") # ÉTAPE 5 : REMPLACEMENT COMPLET LIGNES elif modif_lignes: logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...") nouvelles_lignes = facture_data["lignes"] nb_nouvelles = len(nouvelles_lignes) logger.info( f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles" ) try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentVenteLigne factory_article = self.cial.FactoryArticle # SUPPRESSION TOUTES LES LIGNES if nb_lignes_initial > 0: logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...") for idx in range(nb_lignes_initial, 0, -1): try: ligne_p = factory_lignes.List(idx) if ligne_p: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ligne.Remove() logger.debug(f" ✅ Ligne {idx} supprimée") except Exception as e: logger.warning( f" ⚠️ Erreur suppression ligne {idx}: {e}" ) logger.info(" ✅ Toutes les lignes supprimées") # AJOUT NOUVELLES LIGNES logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...") for idx, ligne_data in enumerate(nouvelles_lignes, 1): persist_article = factory_article.ReadReference( ligne_data["article_code"] ) if not persist_article: raise ValueError( f"Article {ligne_data['article_code']} introuvable" ) article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentVenteLigne3" ) quantite = float(ligne_data["quantite"]) try: ligne_obj.SetDefaultArticleReference( ligne_data["article_code"], quantite ) except: try: ligne_obj.SetDefaultArticle(article_obj, quantite) except: ligne_obj.DL_Design = ligne_data.get("designation", "") ligne_obj.DL_Qte = quantite if ligne_data.get("prix_unitaire_ht"): ligne_obj.DL_PrixUnitaire = float( ligne_data["prix_unitaire_ht"] ) if ligne_data.get("remise_pourcentage", 0) > 0: try: ligne_obj.DL_Remise01REM_Valeur = float( ligne_data["remise_pourcentage"] ) ligne_obj.DL_Remise01REM_Type = 0 except: pass ligne_obj.Write() logger.debug(f" ✅ Ligne {idx} ajoutée") logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées") doc.Write() champs_modifies.append("lignes") # ÉTAPE 6 : RELECTURE ET RETOUR import time time.sleep(1) doc.Read() total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) logger.info(f"✅✅✅ FACTURE MODIFIÉE: {numero} ✅✅✅") logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC") return { "numero": numero, "total_ht": total_ht, "total_ttc": total_ttc, "champs_modifies": champs_modifies, "statut": getattr(doc, "DO_Statut", 0), } 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)}") def creer_article(self, article_data: dict) -> dict: with self._com_context(), self._lock_com: try: logger.info("[ARTICLE] === CREATION ARTICLE ===") # Transaction transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("Transaction Sage démarrée") except Exception as e: logger.debug(f"BeginTrans non disponible : {e}") try: # ======================================== # ÉTAPE 0 : DÉCOUVRIR DÉPÔTS # ======================================== depots_disponibles = [] depot_a_utiliser = None depot_code_demande = article_data.get("depot_code") try: factory_depot = self.cial.FactoryDepot index = 1 while index <= 100: try: persist = factory_depot.List(index) if persist is None: break depot_obj = win32com.client.CastTo(persist, "IBODepot3") depot_obj.Read() code = getattr(depot_obj, "DE_Code", "").strip() if not code: index += 1 continue numero = int(getattr(depot_obj, "Compteur", 0)) intitule = getattr( depot_obj, "DE_Intitule", f"Depot {code}" ) depot_info = { "code": code, "numero": numero, "intitule": intitule, "objet": depot_obj, } depots_disponibles.append(depot_info) if depot_code_demande and code == depot_code_demande: depot_a_utiliser = depot_info elif not depot_code_demande and not depot_a_utiliser: depot_a_utiliser = depot_info index += 1 except Exception as e: if "Acces refuse" in str(e): break index += 1 except Exception as e: logger.warning(f"[DEPOT] Erreur découverte dépôts : {e}") if not depots_disponibles: raise ValueError( "Aucun dépôt trouvé dans Sage. Créez d'abord un dépôt." ) if not depot_a_utiliser: depot_a_utiliser = depots_disponibles[0] logger.info( f"[DEPOT] Utilisation : '{depot_a_utiliser['code']}' ({depot_a_utiliser['intitule']})" ) # ======================================== # ÉTAPE 1 : VALIDATION & NETTOYAGE # ======================================== reference = article_data.get("reference", "").upper().strip() if not reference: raise ValueError("La référence est obligatoire") if len(reference) > 18: raise ValueError( "La référence ne peut pas dépasser 18 caractères" ) designation = article_data.get("designation", "").strip() if not designation: raise ValueError("La désignation est obligatoire") if len(designation) > 69: designation = designation[:69] # Récupération des STOCKS stock_reel = article_data.get("stock_reel", 0.0) stock_mini = article_data.get("stock_mini", 0.0) stock_maxi = article_data.get("stock_maxi", 0.0) logger.info(f"[ARTICLE] Référence : {reference}") logger.info(f"[ARTICLE] Désignation : {designation}") logger.info(f"[ARTICLE] Stock réel demandé : {stock_reel}") logger.info(f"[ARTICLE] Stock mini demandé : {stock_mini}") logger.info(f"[ARTICLE] Stock maxi demandé : {stock_maxi}") # ======================================== # ÉTAPE 2 : VÉRIFIER SI EXISTE DÉJÀ # ======================================== factory = self.cial.FactoryArticle try: article_existant = factory.ReadReference(reference) if article_existant: raise ValueError(f"L'article {reference} existe déjà") except Exception as e: error_msg = str(e) if ( "Enregistrement non trouve" in error_msg or "non trouve" in error_msg or "-2607" in error_msg ): logger.debug( f"[ARTICLE] {reference} n'existe pas encore, création possible" ) else: logger.error(f"[ARTICLE] Erreur vérification : {e}") raise # ======================================== # ÉTAPE 3 : CRÉER L'ARTICLE # ======================================== persist = factory.Create() article = win32com.client.CastTo(persist, "IBOArticle3") article.SetDefault() # Champs de base article.AR_Ref = reference article.AR_Design = designation # ======================================== # ÉTAPE 4 : TROUVER ARTICLE MODÈLE VIA SQL # ======================================== logger.info("[MODELE] Recherche article modèle via SQL...") article_modele_ref = None article_modele = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT TOP 1 AR_Ref FROM F_ARTICLE WHERE AR_Sommeil = 0 ORDER BY AR_Ref """ ) row = cursor.fetchone() if row: article_modele_ref = self._safe_strip(row.AR_Ref) logger.info( f" [SQL] Article modèle trouvé : {article_modele_ref}" ) except Exception as e: logger.warning(f" [SQL] Erreur recherche article : {e}") # Charger l'article modèle via COM if article_modele_ref: try: persist_modele = factory.ReadReference(article_modele_ref) if persist_modele: article_modele = win32com.client.CastTo( persist_modele, "IBOArticle3" ) article_modele.Read() logger.info( f" [OK] Article modèle chargé : {article_modele_ref}" ) except Exception as e: logger.warning(f" [WARN] Erreur chargement modèle : {e}") article_modele = None if not article_modele: raise ValueError( "Aucun article modèle trouvé dans Sage.\n" "Créez au moins un article manuellement dans Sage pour servir de modèle." ) # ======================================== # ÉTAPE 5 : COPIER UNITÉ + FAMILLE # ======================================== logger.info("[OBJETS] Copie Unite + Famille depuis modèle...") # Unite unite_trouvee = False try: unite_obj = getattr(article_modele, "Unite", None) if unite_obj: article.Unite = unite_obj logger.info( f" [OK] Objet Unite copié depuis {article_modele_ref}" ) unite_trouvee = True except Exception as e: logger.debug(f" Unite non copiable : {str(e)[:80]}") if not unite_trouvee: raise ValueError( "Impossible de copier l'unité de vente depuis le modèle" ) # Famille famille_trouvee = False famille_code_personnalise = article_data.get("famille") famille_obj = None if famille_code_personnalise: logger.info( f" [FAMILLE] Code personnalisé demandé : {famille_code_personnalise}" ) try: # Vérifier existence via SQL famille_existe_sql = False famille_code_exact = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT FA_CodeFamille, FA_Type FROM F_FAMILLE WHERE UPPER(FA_CodeFamille) = ? """, (famille_code_personnalise.upper(),), ) row = cursor.fetchone() if row: famille_code_exact = self._safe_strip( row.FA_CodeFamille ) famille_existe_sql = True logger.info( f" [SQL] Famille trouvée : {famille_code_exact}" ) else: raise ValueError( f"Famille '{famille_code_personnalise}' introuvable" ) except ValueError: raise except Exception as e_sql: logger.warning(f" [SQL] Erreur : {e_sql}") # Charger via COM if famille_existe_sql and famille_code_exact: factory_famille = self.cial.FactoryFamille try: index = 1 max_scan = 1000 while index <= max_scan: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_test = ( getattr(fam_test, "FA_CodeFamille", "") .strip() .upper() ) if code_test == famille_code_exact.upper(): famille_obj = fam_test famille_trouvee = True logger.info( f" [OK] Famille trouvée à l'index {index}" ) break index += 1 except Exception as e: if "Accès refusé" in str(e): break index += 1 if famille_obj: famille_obj.Read() article.Famille = famille_obj logger.info( f" [OK] Famille '{famille_code_personnalise}' assignée" ) else: raise ValueError( f"Famille '{famille_code_personnalise}' inaccessible via COM" ) except Exception as e: logger.warning(f" [COM] Erreur scanner : {e}") raise except ValueError: raise except Exception as e: logger.warning( f" [WARN] Famille personnalisée non chargée : {str(e)[:100]}" ) # Si pas de famille perso, copier depuis le modèle if not famille_trouvee: try: famille_obj = getattr(article_modele, "Famille", None) if famille_obj: article.Famille = famille_obj logger.info( f" [OK] Objet Famille copié depuis {article_modele_ref}" ) famille_trouvee = True except Exception as e: logger.debug(f" Famille non copiable : {str(e)[:80]}") # ======================================== # ÉTAPE 6 : CHAMPS OBLIGATOIRES # ======================================== logger.info("[CHAMPS] Copie champs obligatoires depuis modèle...") # Types et natures article.AR_Type = int(getattr(article_modele, "AR_Type", 0)) article.AR_Nature = int(getattr(article_modele, "AR_Nature", 0)) article.AR_Nomencl = int(getattr(article_modele, "AR_Nomencl", 0)) # Suivi stock (forcé à 2 = FIFO/LIFO) article.AR_SuiviStock = 2 logger.info(f" [OK] AR_SuiviStock=2 (FIFO/LIFO)") # ======================================== # ÉTAPE 7 : PRIX # ======================================== prix_vente = article_data.get("prix_vente") if prix_vente is not None: try: article.AR_PrixVen = float(prix_vente) logger.info(f" Prix vente : {prix_vente} EUR") except Exception as e: logger.warning(f" Prix vente erreur : {str(e)[:100]}") prix_achat = article_data.get("prix_achat") if prix_achat is not None: try: try: article.AR_PrixAch = float(prix_achat) logger.info( f" Prix achat (AR_PrixAch) : {prix_achat} EUR" ) except: article.AR_PrixAchat = float(prix_achat) logger.info( f" Prix achat (AR_PrixAchat) : {prix_achat} EUR" ) except Exception as e: logger.warning(f" Prix achat erreur : {str(e)[:100]}") # ======================================== # ÉTAPE 8 : CODE EAN # ======================================== code_ean = article_data.get("code_ean") if code_ean: article.AR_CodeBarre = str(code_ean) logger.info(f" Code EAN/Barre : {code_ean}") # ======================================== # ÉTAPE 9 : DESCRIPTION # ======================================== description = article_data.get("description") if description: try: article.AR_Commentaire = description logger.info(f" Description définie") except: pass # ======================================== # ÉTAPE 10 : ÉCRITURE ARTICLE # ======================================== logger.info("[ARTICLE] Écriture dans Sage...") try: article.Write() logger.info(" [OK] Write() réussi") except Exception as e: error_detail = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = f"{sage_error.Description} (Code: {sage_error.Number})" except: pass logger.error(f" [ERREUR] Write() échoué : {error_detail}") raise RuntimeError(f"Échec création article : {error_detail}") # ======================================== # ÉTAPE 11 : DÉFINIR LE STOCK DANS F_ARTSTOCK (CRITIQUE) # ======================================== stock_defini = False stock_erreur = None # Vérifier si on a des valeurs de stock à définir has_stock_values = stock_reel or stock_mini or stock_maxi if has_stock_values: logger.info( f"[STOCK] Définition stock dans F_ARTSTOCK (dépôt '{depot_a_utiliser['code']}')..." ) try: depot_obj = depot_a_utiliser["objet"] # Chercher FactoryArticleStock ou FactoryDepotStock factory_stock = None for factory_name in [ "FactoryArticleStock", "FactoryDepotStock", ]: try: factory_stock = getattr( depot_obj, factory_name, None ) if factory_stock: logger.info( f" Factory trouvée : {factory_name}" ) break except: continue if not factory_stock: raise RuntimeError( "Factory de stock introuvable sur le dépôt" ) # Créer l'entrée de stock dans F_ARTSTOCK stock_persist = factory_stock.Create() stock_obj = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) stock_obj.SetDefault() # Référence article stock_obj.AR_Ref = reference # Stock réel if stock_reel: stock_obj.AS_QteSto = float(stock_reel) logger.info(f" AS_QteSto = {stock_reel}") # Stock minimum if stock_mini: try: stock_obj.AS_QteMini = float(stock_mini) logger.info(f" AS_QteMini = {stock_mini}") except Exception as e: logger.warning(f" AS_QteMini non défini : {e}") # Stock maximum if stock_maxi: try: stock_obj.AS_QteMaxi = float(stock_maxi) logger.info(f" AS_QteMaxi = {stock_maxi}") except Exception as e: logger.warning(f" AS_QteMaxi non défini : {e}") stock_obj.Write() stock_defini = True logger.info( f" [OK] Stock défini dans F_ARTSTOCK : réel={stock_reel}, min={stock_mini}, max={stock_maxi}" ) except Exception as e: stock_erreur = str(e) logger.error( f" [ERREUR] Stock non défini dans F_ARTSTOCK : {e}", exc_info=True, ) # ======================================== # ÉTAPE 12 : COMMIT # ======================================== if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info( "[COMMIT] Transaction committée - Article persiste dans Sage" ) except Exception as e: logger.warning(f"[COMMIT] Erreur commit : {e}") # ======================================== # ÉTAPE 13 : VÉRIFICATION & RELECTURE # ======================================== logger.info("[VERIF] Relecture article créé...") article_cree_persist = factory.ReadReference(reference) if not article_cree_persist: raise RuntimeError( "Article créé mais introuvable à la relecture" ) article_cree = win32com.client.CastTo( article_cree_persist, "IBOArticle3" ) article_cree.Read() # ======================================== # ÉTAPE 14 : VÉRIFIER LE STOCK DANS F_ARTSTOCK VIA SQL # ======================================== stocks_par_depot = [] stock_total = 0.0 try: with self._get_sql_connection() as conn: cursor = conn.cursor() # Vérifier si le stock a été créé dans F_ARTSTOCK cursor.execute( """ SELECT d.DE_Code, s.AS_QteSto, s.AS_QteMini, s.AS_QteMaxi FROM F_ARTSTOCK s LEFT JOIN F_DEPOT d ON s.DE_No = d.DE_No WHERE s.AR_Ref = ? """, (reference.upper(),), ) depot_rows = cursor.fetchall() for depot_row in depot_rows: if len(depot_row) >= 4: qte = float(depot_row[1]) if depot_row[1] else 0.0 stock_total += qte stocks_par_depot.append( { "depot_code": self._safe_strip( depot_row[0] ), "quantite": qte, "qte_mini": ( float(depot_row[2]) if depot_row[2] else 0.0 ), "qte_maxi": ( float(depot_row[3]) if depot_row[3] else 0.0 ), } ) logger.info( f"[VERIF] Stock dans F_ARTSTOCK : {stock_total} unités dans {len(stocks_par_depot)} dépôt(s)" ) except Exception as e: logger.warning( f"[VERIF] Impossible de vérifier le stock dans F_ARTSTOCK : {e}" ) logger.info( f"[OK] ARTICLE CREE : {reference} - Stock total : {stock_total}" ) # ======================================== # ÉTAPE 15 : EXTRACTION COMPLÈTE # ======================================== logger.info("[EXTRACTION] Extraction complète de l'article créé...") # Utiliser _extraire_article() pour avoir TOUS les champs resultat = self._extraire_article(article_cree) if not resultat: # Fallback si extraction échoue resultat = { "reference": reference, "designation": designation, } # ======================================== # ÉTAPE 16 : FORCER LES VALEURS DE STOCK DEPUIS F_ARTSTOCK # ======================================== # ✅ 1. STOCK (forcer les valeurs depuis F_ARTSTOCK) resultat["stock_reel"] = stock_total if stock_mini: resultat["stock_mini"] = float(stock_mini) if stock_maxi: resultat["stock_maxi"] = float(stock_maxi) # Stock disponible = stock réel (article neuf, pas de réservation) resultat["stock_disponible"] = stock_total resultat["stock_reserve"] = 0.0 resultat["stock_commande"] = 0.0 # ✅ 2. PRIX if prix_vente is not None: resultat["prix_vente"] = float(prix_vente) if prix_achat is not None: resultat["prix_achat"] = float(prix_achat) # ✅ 3. DESCRIPTION if description: resultat["description"] = description # ✅ 4. CODE EAN if code_ean: resultat["code_ean"] = str(code_ean) resultat["code_barre"] = str(code_ean) # ✅ 5. FAMILLE if famille_code_personnalise and famille_trouvee: resultat["famille_code"] = famille_code_personnalise try: if famille_obj: famille_obj.Read() resultat["famille_libelle"] = getattr( famille_obj, "FA_Intitule", "" ) except: pass # ✅ 6. INFOS DÉPÔTS if stocks_par_depot: resultat["stocks_par_depot"] = stocks_par_depot resultat["depot_principal"] = { "code": depot_a_utiliser["code"], "intitule": depot_a_utiliser["intitule"], } # ✅ 7. SUIVI DE STOCK resultat["suivi_stock_active"] = stock_defini # ✅ 8. AVERTISSEMENT SI STOCK NON DÉFINI if has_stock_values and not stock_defini and stock_erreur: resultat["avertissement"] = ( f"Stock demandé mais non défini dans F_ARTSTOCK : {stock_erreur}" ) logger.info( f"[EXTRACTION] ✅ Article extrait et enrichi avec {len(resultat)} champs" ) return resultat except ValueError: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass raise except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except: pass logger.error(f"Erreur creation article : {e}", exc_info=True) raise RuntimeError(f"Erreur creation article : {str(e)}") except Exception as e: logger.error(f"Erreur globale : {e}", exc_info=True) raise def modifier_article(self, reference: str, article_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"[ARTICLE] === MODIFICATION {reference} ===") # ======================================== # ÉTAPE 1 : CHARGER L'ARTICLE EXISTANT # ======================================== factory_article = self.cial.FactoryArticle persist = factory_article.ReadReference(reference.upper()) if not persist: raise ValueError(f"Article {reference} introuvable") article = win32com.client.CastTo(persist, "IBOArticle3") article.Read() designation_actuelle = getattr(article, "AR_Design", "") logger.info(f"[ARTICLE] Trouvé : {reference} - {designation_actuelle}") # ======================================== # ÉTAPE 2 : METTRE À JOUR LES CHAMPS # ======================================== logger.info("[ARTICLE] Mise à jour des champs...") champs_modifies = [] # ======================================== # 🆕 FAMILLE (NOUVEAU - avec scanner List) # ======================================== if "famille" in article_data and article_data["famille"]: famille_code_demande = article_data["famille"].upper().strip() logger.info( f"[FAMILLE] Changement demandé : {famille_code_demande}" ) try: # ======================================== # VÉRIFIER EXISTENCE VIA SQL # ======================================== famille_existe_sql = False famille_code_exact = None famille_type = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT FA_CodeFamille, FA_Type FROM F_FAMILLE WHERE UPPER(FA_CodeFamille) = ? """, (famille_code_demande,), ) row = cursor.fetchone() if row: famille_code_exact = self._safe_strip( row.FA_CodeFamille ) famille_type = row.FA_Type if len(row) > 1 else 0 famille_existe_sql = True # Vérifier le type if famille_type == 1: raise ValueError( f"La famille '{famille_code_demande}' est de type 'Total' " f"et ne peut pas contenir d'articles. " f"Utilisez une famille de type Détail." ) logger.info( f" [SQL] Famille trouvée : {famille_code_exact} (type={famille_type})" ) else: raise ValueError( f"Famille '{famille_code_demande}' introuvable dans Sage" ) except ValueError: raise except Exception as e: logger.warning(f" [SQL] Erreur : {e}") raise ValueError(f"Impossible de vérifier la famille : {e}") # ======================================== # CHARGER VIA COM (SCANNER) # ======================================== if famille_existe_sql and famille_code_exact: logger.info(f" [COM] Recherche via scanner...") factory_famille = self.cial.FactoryFamille famille_obj = None # Scanner List() try: index = 1 max_scan = 1000 while index <= max_scan: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_test = ( getattr(fam_test, "FA_CodeFamille", "") .strip() .upper() ) if code_test == famille_code_exact.upper(): # TROUVÉ ! famille_obj = fam_test logger.info( f" [OK] Famille trouvée à l'index {index}" ) break index += 1 except Exception as e: if "Accès refusé" in str(e) or "Access" in str( e ): break index += 1 except Exception as e: logger.warning( f" [COM] Scanner échoué : {str(e)[:200]}" ) # Assigner la famille if famille_obj: famille_obj.Read() article.Famille = famille_obj champs_modifies.append(f"famille={famille_code_exact}") logger.info( f" [OK] Famille changée : {famille_code_exact}" ) else: raise ValueError( f"Famille '{famille_code_demande}' trouvée en SQL mais inaccessible via COM. " f"Essayez avec une autre famille." ) except ValueError: raise except Exception as e: logger.error(f" [ERREUR] Changement famille : {e}") raise ValueError(f"Impossible de changer la famille : {str(e)}") # ======================================== # DÉSIGNATION # ======================================== if "designation" in article_data: designation = str(article_data["designation"])[:69].strip() article.AR_Design = designation champs_modifies.append(f"designation") logger.info(f" [OK] Désignation : {designation}") # ======================================== # PRIX DE VENTE # ======================================== if "prix_vente" in article_data: try: prix_vente = float(article_data["prix_vente"]) article.AR_PrixVen = prix_vente champs_modifies.append("prix_vente") logger.info(f" [OK] Prix vente : {prix_vente} EUR") except Exception as e: logger.warning(f" [WARN] Prix vente : {e}") # ======================================== # PRIX D'ACHAT # ======================================== if "prix_achat" in article_data: try: prix_achat = float(article_data["prix_achat"]) # Double tentative (AR_PrixAch / AR_PrixAchat) try: article.AR_PrixAch = prix_achat champs_modifies.append("prix_achat") logger.info( f" [OK] Prix achat (AR_PrixAch) : {prix_achat} EUR" ) except: article.AR_PrixAchat = prix_achat champs_modifies.append("prix_achat") logger.info( f" [OK] Prix achat (AR_PrixAchat) : {prix_achat} EUR" ) except Exception as e: logger.warning(f" [WARN] Prix achat : {e}") # ======================================== # STOCK RÉEL (NIVEAU ARTICLE) # ======================================== if "stock_reel" in article_data: try: stock_reel = float(article_data["stock_reel"]) ancien_stock = float(getattr(article, "AR_Stock", 0.0)) article.AR_Stock = stock_reel champs_modifies.append("stock_reel") logger.info(f" [OK] Stock : {ancien_stock} -> {stock_reel}") if stock_reel > ancien_stock: logger.info( f" [+] Stock augmenté de {stock_reel - ancien_stock}" ) except Exception as e: logger.error(f" [ERREUR] Stock : {e}") raise ValueError(f"Impossible de modifier le stock: {e}") # ======================================== # STOCK MINI/MAXI (NIVEAU ARTICLE) # ======================================== if "stock_mini" in article_data: try: stock_mini = float(article_data["stock_mini"]) article.AR_StockMini = stock_mini champs_modifies.append("stock_mini") logger.info(f" [OK] Stock mini : {stock_mini}") except Exception as e: logger.warning(f" [WARN] Stock mini : {e}") if "stock_maxi" in article_data: try: stock_maxi = float(article_data["stock_maxi"]) article.AR_StockMaxi = stock_maxi champs_modifies.append("stock_maxi") logger.info(f" [OK] Stock maxi : {stock_maxi}") except Exception as e: logger.warning(f" [WARN] Stock maxi : {e}") # ======================================== # CODE EAN # ======================================== if "code_ean" in article_data: try: code_ean = str(article_data["code_ean"])[:13].strip() article.AR_CodeBarre = code_ean champs_modifies.append("code_ean") logger.info(f" [OK] Code EAN : {code_ean}") except Exception as e: logger.warning(f" [WARN] Code EAN : {e}") # ======================================== # DESCRIPTION # ======================================== if "description" in article_data: try: description = str(article_data["description"])[:255].strip() article.AR_Commentaire = description champs_modifies.append("description") logger.info(f" [OK] Description définie") except Exception as e: logger.warning(f" [WARN] Description : {e}") # ======================================== # VÉRIFICATION # ======================================== if not champs_modifies: logger.warning("[ARTICLE] Aucun champ à modifier") return self._extraire_article(article) logger.info( f"[ARTICLE] Champs à modifier : {', '.join(champs_modifies)}" ) # ======================================== # ÉCRITURE # ======================================== logger.info("[ARTICLE] Écriture des modifications...") try: article.Write() logger.info("[ARTICLE] Write() réussi") except Exception as e: error_detail = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = ( f"{sage_error.Description} (Code: {sage_error.Number})" ) except: pass logger.error(f"[ARTICLE] Erreur Write() : {error_detail}") raise RuntimeError(f"Échec modification : {error_detail}") # ======================================== # RELECTURE ET EXTRACTION # ======================================== article.Read() logger.info( f"[ARTICLE] MODIFIÉ : {reference} ({len(champs_modifies)} champs)" ) # Extraction complète resultat = self._extraire_article(article) if not resultat: # Fallback si extraction échoue resultat = { "reference": reference, "designation": getattr(article, "AR_Design", ""), } # Enrichir avec les valeurs qu'on vient de modifier if "prix_vente" in article_data: resultat["prix_vente"] = float(article_data["prix_vente"]) if "prix_achat" in article_data: resultat["prix_achat"] = float(article_data["prix_achat"]) if "stock_reel" in article_data: resultat["stock_reel"] = float(article_data["stock_reel"]) if "stock_mini" in article_data: resultat["stock_mini"] = float(article_data["stock_mini"]) if "stock_maxi" in article_data: resultat["stock_maxi"] = float(article_data["stock_maxi"]) if "code_ean" in article_data: resultat["code_ean"] = str(article_data["code_ean"]) resultat["code_barre"] = str(article_data["code_ean"]) if "description" in article_data: resultat["description"] = str(article_data["description"]) if "famille" in article_data: resultat["famille_code"] = ( famille_code_exact if "famille_code_exact" in locals() else "" ) return resultat except ValueError as e: logger.error(f"[ARTICLE] Erreur métier : {e}") raise except Exception as e: logger.error(f"[ARTICLE] Erreur technique : {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def creer_famille(self, famille_data: dict) -> dict: """ ✅ Crée une nouvelle famille d'articles dans Sage 100c **RESTRICTION : Seules les familles de type DÉTAIL peuvent être créées** Les familles de type Total doivent être créées manuellement dans Sage. Args: famille_data: { "code": str (obligatoire, max 18 car, ex: "ALIM"), "intitule": str (obligatoire, max 69 car, ex: "Produits alimentaires"), "type": int (IGNORÉ - toujours 0=Détail), "compte_achat": str (optionnel, ex: "607000"), "compte_vente": str (optionnel, ex: "707000") } Returns: dict: Famille créée avec tous ses attributs Raises: ValueError: Si la famille existe déjà ou données invalides RuntimeError: Si erreur technique Sage """ with self._com_context(), self._lock_com: try: logger.info("[FAMILLE] === CRÉATION FAMILLE (TYPE DÉTAIL) ===") # ======================================== # VALIDATION # ======================================== code = famille_data.get("code", "").upper().strip() if not code: raise ValueError("Le code famille est obligatoire") if len(code) > 18: raise ValueError( "Le code famille ne peut pas dépasser 18 caractères" ) intitule = famille_data.get("intitule", "").strip() if not intitule: raise ValueError("L'intitulé est obligatoire") if len(intitule) > 69: intitule = intitule[:69] logger.info(f"[FAMILLE] Code : {code}") logger.info(f"[FAMILLE] Intitulé : {intitule}") # ✅ NOUVEAU : Avertir si l'utilisateur demande un type Total type_demande = famille_data.get("type", 0) if type_demande == 1: logger.warning( "[FAMILLE] Type 'Total' demandé mais IGNORÉ - Création en type Détail uniquement" ) # ======================================== # VÉRIFIER SI EXISTE DÉJÀ # ======================================== factory_famille = self.cial.FactoryFamille try: # Scanner pour vérifier l'existence index = 1 while index <= 1000: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_existant = ( getattr(fam_test, "FA_CodeFamille", "").strip().upper() ) if code_existant == code: raise ValueError(f"La famille {code} existe déjà") index += 1 except ValueError: raise # Re-raise si c'est notre erreur except: index += 1 except ValueError: raise # ======================================== # CRÉER LA FAMILLE # ======================================== persist = factory_famille.Create() famille = win32com.client.CastTo(persist, "IBOFamille3") famille.SetDefault() # Champs obligatoires famille.FA_CodeFamille = code famille.FA_Intitule = intitule # ✅ CRITIQUE : FORCER Type = 0 (Détail) try: famille.FA_Type = 0 # ✅ Toujours Détail logger.info(f"[FAMILLE] Type : 0 (Détail)") except Exception as e: logger.warning(f"[FAMILLE] FA_Type non défini : {e}") # Comptes généraux (optionnels) compte_achat = famille_data.get("compte_achat") if compte_achat: try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte_achat) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() famille.CompteGAchat = compte_obj logger.info(f"[FAMILLE] Compte achat : {compte_achat}") except Exception as e: logger.warning(f"[FAMILLE] Compte achat non défini : {e}") compte_vente = famille_data.get("compte_vente") if compte_vente: try: factory_compte = self.cial.CptaApplication.FactoryCompteG persist_compte = factory_compte.ReadNumero(compte_vente) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() famille.CompteGVente = compte_obj logger.info(f"[FAMILLE] Compte vente : {compte_vente}") except Exception as e: logger.warning(f"[FAMILLE] Compte vente non défini : {e}") # ======================================== # ÉCRIRE DANS SAGE # ======================================== logger.info("[FAMILLE] Écriture dans Sage...") try: famille.Write() logger.info("[FAMILLE] Write() réussi") except Exception as e: error_detail = str(e) try: sage_error = self.cial.CptaApplication.LastError if sage_error: error_detail = ( f"{sage_error.Description} (Code: {sage_error.Number})" ) except: pass logger.error(f"[FAMILLE] Erreur Write() : {error_detail}") raise RuntimeError(f"Échec création famille : {error_detail}") # ======================================== # RELIRE ET RETOURNER # ======================================== famille.Read() resultat = { "code": getattr(famille, "FA_CodeFamille", "").strip(), "intitule": getattr(famille, "FA_Intitule", "").strip(), "type": 0, # ✅ Toujours Détail "type_libelle": "Détail", } logger.info(f"[FAMILLE] FAMILLE CRÉÉE : {code} - {intitule} (Détail)") return resultat except ValueError as e: logger.error(f"[FAMILLE] Erreur métier : {e}") raise except Exception as e: logger.error(f"[FAMILLE] Erreur technique : {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def lister_toutes_familles( self, filtre: str = "", inclure_totaux: bool = False ) -> List[Dict]: try: with self._get_sql_connection() as conn: cursor = conn.cursor() logger.info("[SQL] Détection des colonnes de F_FAMILLE...") # Requête de test pour récupérer les métadonnées cursor.execute("SELECT TOP 1 * FROM F_FAMILLE") colonnes_disponibles = [column[0] for column in cursor.description] logger.info(f"[SQL] Colonnes trouvées : {len(colonnes_disponibles)}") colonnes_souhaitees = [ "FA_CodeFamille", "FA_Intitule", "FA_Type", "FA_UniteVen", "FA_Coef", "FA_Central", "FA_Nature", "CG_NumAch", "CG_NumVte", "FA_Stat", "FA_Raccourci", ] # Ne garder QUE les colonnes qui existent vraiment colonnes_a_lire = [ col for col in colonnes_souhaitees if col in colonnes_disponibles ] if not colonnes_a_lire: colonnes_a_lire = colonnes_disponibles logger.info(f"[SQL] Colonnes sélectionnées : {len(colonnes_a_lire)}") colonnes_str = ", ".join(colonnes_a_lire) query = f""" SELECT {colonnes_str} FROM F_FAMILLE WHERE 1=1 """ params = [] if "FA_Type" in colonnes_disponibles: if not inclure_totaux: query += " AND FA_Type = 0" # ✅ Seulement Détail logger.info("[SQL] Filtre : FA_Type = 0 (Détail uniquement)") else: logger.info("[SQL] Filtre : TOUS les types (Détail + Total)") # Filtre texte (si fourni) if filtre: conditions_filtre = [] if "FA_CodeFamille" in colonnes_a_lire: conditions_filtre.append("FA_CodeFamille LIKE ?") params.append(f"%{filtre}%") if "FA_Intitule" in colonnes_a_lire: conditions_filtre.append("FA_Intitule LIKE ?") params.append(f"%{filtre}%") if conditions_filtre: query += " AND (" + " OR ".join(conditions_filtre) + ")" # Tri if "FA_Intitule" in colonnes_a_lire: query += " ORDER BY FA_Intitule" elif "FA_CodeFamille" in colonnes_a_lire: query += " ORDER BY FA_CodeFamille" cursor.execute(query, params) rows = cursor.fetchall() familles = [] for row in rows: famille = {} # Remplir avec les colonnes disponibles for idx, colonne in enumerate(colonnes_a_lire): valeur = row[idx] if isinstance(valeur, str): valeur = valeur.strip() famille[colonne] = valeur # Alias if "FA_CodeFamille" in famille: famille["code"] = famille["FA_CodeFamille"] if "FA_Intitule" in famille: famille["intitule"] = famille["FA_Intitule"] # Type lisible if "FA_Type" in famille: type_val = famille["FA_Type"] famille["type"] = type_val famille["type_libelle"] = "Total" if type_val == 1 else "Détail" famille["est_total"] = type_val == 1 else: famille["type"] = 0 famille["type_libelle"] = "Détail" famille["est_total"] = False # Autres champs famille["unite_vente"] = str(famille.get("FA_UniteVen", "")) famille["coef"] = ( float(famille.get("FA_Coef", 0.0)) if famille.get("FA_Coef") is not None else 0.0 ) famille["compte_achat"] = famille.get("CG_NumAch", "") famille["compte_vente"] = famille.get("CG_NumVte", "") famille["est_statistique"] = ( (famille.get("FA_Stat") == 1) if "FA_Stat" in famille else False ) famille["est_centrale"] = ( (famille.get("FA_Central") == 1) if "FA_Central" in famille else False ) famille["nature"] = famille.get("FA_Nature", 0) familles.append(famille) type_msg = "DÉTAIL uniquement" if not inclure_totaux else "TOUS types" logger.info(f"SQL: {len(familles)} familles chargées ({type_msg})") return familles except Exception as e: logger.error(f"Erreur SQL familles: {e}", exc_info=True) raise RuntimeError(f"Erreur lecture familles: {str(e)}") def lire_famille(self, code: str) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[FAMILLE] Lecture : {code}") code_recherche = code.upper().strip() famille_existe_sql = False famille_code_exact = None famille_type_sql = None famille_intitule_sql = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() # Détecter les colonnes disponibles cursor.execute("SELECT TOP 1 * FROM F_FAMILLE") colonnes_disponibles = [col[0] for col in cursor.description] # Construire la requête selon les colonnes disponibles colonnes_select = ["FA_CodeFamille", "FA_Intitule"] if "FA_Type" in colonnes_disponibles: colonnes_select.append("FA_Type") colonnes_str = ", ".join(colonnes_select) cursor.execute( f""" SELECT {colonnes_str} FROM F_FAMILLE WHERE UPPER(FA_CodeFamille) = ? """, (code_recherche,), ) row = cursor.fetchone() if row: famille_existe_sql = True famille_code_exact = self._safe_strip(row.FA_CodeFamille) famille_intitule_sql = self._safe_strip(row.FA_Intitule) # Type (si disponible) if "FA_Type" in colonnes_disponibles and len(row) > 2: famille_type_sql = row.FA_Type logger.info( f" [SQL] Trouvée : {famille_code_exact} - {famille_intitule_sql} (type={famille_type_sql})" ) else: raise ValueError(f"Famille '{code}' introuvable dans Sage") except ValueError: raise except Exception as e: logger.warning(f" [SQL] Erreur : {e}") # Continuer quand même avec COM if not famille_code_exact: famille_code_exact = code_recherche logger.info( f" [COM] Recherche de '{famille_code_exact}' via scanner..." ) factory_famille = self.cial.FactoryFamille famille_obj = None index_trouve = None try: index = 1 max_scan = 2000 # Scanner jusqu'à 2000 familles while index <= max_scan: try: persist_test = factory_famille.List(index) if persist_test is None: break fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() code_test = ( getattr(fam_test, "FA_CodeFamille", "").strip().upper() ) if code_test == famille_code_exact: # TROUVÉE ! famille_obj = fam_test index_trouve = index logger.info(f" [OK] Famille trouvée à l'index {index}") break index += 1 except Exception as e: if "Accès refusé" in str(e) or "Access" in str(e): break index += 1 if not famille_obj: if famille_existe_sql: raise ValueError( f"Famille '{code}' trouvée en SQL mais inaccessible via COM. " f"Vérifiez les permissions." ) else: raise ValueError(f"Famille '{code}' introuvable") except ValueError: raise except Exception as e: logger.error(f" [COM] Erreur scanner : {e}") raise RuntimeError(f"Erreur chargement famille : {str(e)}") logger.info("[FAMILLE] Extraction des informations...") famille_obj.Read() # Champs de base resultat = { "code": getattr(famille_obj, "FA_CodeFamille", "").strip(), "intitule": getattr(famille_obj, "FA_Intitule", "").strip(), } # Type try: fa_type = getattr(famille_obj, "FA_Type", 0) resultat["type"] = fa_type resultat["type_libelle"] = "Total" if fa_type == 1 else "Détail" resultat["est_total"] = fa_type == 1 resultat["est_detail"] = fa_type == 0 # ⚠️ Avertissement si famille Total if fa_type == 1: resultat["avertissement"] = ( "Cette famille est de type 'Total' (agrégation comptable) " "et ne peut pas contenir d'articles directement." ) logger.warning( f" [TYPE] Famille Total détectée : {resultat['code']}" ) except: resultat["type"] = 0 resultat["type_libelle"] = "Détail" resultat["est_total"] = False resultat["est_detail"] = True # Unité de vente try: resultat["unite_vente"] = getattr( famille_obj, "FA_UniteVen", "" ).strip() except: resultat["unite_vente"] = "" # Coefficient try: coef = getattr(famille_obj, "FA_Coef", None) resultat["coef"] = float(coef) if coef is not None else 0.0 except: resultat["coef"] = 0.0 # Nature try: resultat["nature"] = getattr(famille_obj, "FA_Nature", 0) except: resultat["nature"] = 0 # Centrale d'achat try: central = getattr(famille_obj, "FA_Central", None) resultat["est_centrale"] = ( (central == 1) if central is not None else False ) except: resultat["est_centrale"] = False # Statistique try: stat = getattr(famille_obj, "FA_Stat", None) resultat["est_statistique"] = ( (stat == 1) if stat is not None else False ) except: resultat["est_statistique"] = False # Raccourci try: resultat["raccourci"] = getattr( famille_obj, "FA_Raccourci", "" ).strip() except: resultat["raccourci"] = "" # ======================================== # COMPTES GÉNÉRAUX # ======================================== # Compte achat try: compte_achat_obj = getattr(famille_obj, "CompteGAchat", None) if compte_achat_obj: compte_achat_obj.Read() resultat["compte_achat"] = getattr( compte_achat_obj, "CG_Num", "" ).strip() else: resultat["compte_achat"] = "" except: resultat["compte_achat"] = "" # Compte vente try: compte_vente_obj = getattr(famille_obj, "CompteGVente", None) if compte_vente_obj: compte_vente_obj.Read() resultat["compte_vente"] = getattr( compte_vente_obj, "CG_Num", "" ).strip() else: resultat["compte_vente"] = "" except: resultat["compte_vente"] = "" # Index de lecture resultat["index_com"] = index_trouve # Dates (si disponibles) try: date_creation = getattr(famille_obj, "cbCreation", None) resultat["date_creation"] = ( str(date_creation) if date_creation else "" ) except: resultat["date_creation"] = "" try: date_modif = getattr(famille_obj, "cbModification", None) resultat["date_modification"] = ( str(date_modif) if date_modif else "" ) except: resultat["date_modification"] = "" # Compter les articles de cette famille via SQL try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """ SELECT COUNT(*) FROM F_ARTICLE WHERE FA_CodeFamille = ? """, (resultat["code"],), ) row = cursor.fetchone() if row: resultat["nb_articles"] = row[0] logger.info( f" [STAT] {resultat['nb_articles']} article(s) dans cette famille" ) except Exception as e: logger.warning(f" [STAT] Impossible de compter les articles : {e}") resultat["nb_articles"] = None logger.info( f"[FAMILLE] Lecture complète : {resultat['code']} - {resultat['intitule']}" ) return resultat except ValueError as e: logger.error(f"[FAMILLE] Erreur métier : {e}") raise except Exception as e: logger.error(f"[FAMILLE] Erreur technique : {e}", exc_info=True) error_message = str(e) if self.cial: try: err = self.cial.CptaApplication.LastError if err: error_message = f"Erreur Sage: {err.Description}" except: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def creer_entree_stock(self, entree_data: Dict) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] === CRÉATION ENTRÉE STOCK (COM pur) ===") # Démarrer transaction transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("Transaction démarrée") except: pass try: # ======================================== # ÉTAPE 1 : CRÉER LE DOCUMENT D'ENTRÉE # ======================================== factory_doc = self.cial.FactoryDocumentStock persist_doc = factory_doc.CreateType(180) # 180 = Entrée doc = win32com.client.CastTo(persist_doc, "IBODocumentStock3") doc.SetDefault() import pywintypes date_mouv = entree_data.get("date_mouvement") if isinstance(date_mouv, date): doc.DO_Date = pywintypes.Time( datetime.combine(date_mouv, datetime.min.time()) ) else: doc.DO_Date = pywintypes.Time(datetime.now()) if entree_data.get("reference"): doc.DO_Ref = entree_data["reference"] doc.Write() logger.info(f"[STOCK] Document créé") # ======================================== # ÉTAPE 2 : PRÉPARER POUR LES STOCKS MINI/MAXI # ======================================== factory_article = self.cial.FactoryArticle factory_depot = self.cial.FactoryDepot stocks_mis_a_jour = [] depot_principal = None # Trouver un dépôt principal try: persist_depot = factory_depot.List(1) if persist_depot: depot_principal = win32com.client.CastTo( persist_depot, "IBODepot3" ) depot_principal.Read() logger.info( f"Dépôt principal: {getattr(depot_principal, 'DE_Code', '?')}" ) except Exception as e: logger.warning(f"Erreur chargement dépôt: {e}") # ======================================== # ÉTAPE 3 : TRAITER CHAQUE LIGNE (MOUVEMENT + STOCK) # ======================================== try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentStockLigne for idx, ligne_data in enumerate(entree_data["lignes"], 1): article_ref = ligne_data["article_ref"].upper() quantite = ligne_data["quantite"] stock_mini = ligne_data.get("stock_mini") stock_maxi = ligne_data.get("stock_maxi") logger.info(f"[STOCK] Ligne {idx}: {article_ref} x {quantite}") # A. CHARGER L'ARTICLE persist_article = factory_article.ReadReference(article_ref) if not persist_article: raise ValueError(f"Article {article_ref} introuvable") article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() # B. CRÉER LA LIGNE DE MOUVEMENT ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentStockLigne3" ) ligne_obj.SetDefault() # Lier l'article au mouvement try: ligne_obj.SetDefaultArticleReference( article_ref, float(quantite) ) except: try: ligne_obj.SetDefaultArticle( article_obj, float(quantite) ) except: raise ValueError( f"Impossible de lier l'article {article_ref}" ) # Prix prix = ligne_data.get("prix_unitaire") if prix: try: ligne_obj.DL_PrixUnitaire = float(prix) except: pass # Écrire la ligne ligne_obj.Write() # ======================================== # ÉTAPE 4 : GÉRER LES STOCKS MINI/MAXI (COM PUR) # ======================================== if stock_mini is not None or stock_maxi is not None: logger.info( f"[STOCK] Ajustement stock pour {article_ref}..." ) try: # MÉTHODE A : Via Article.FactoryArticleStock (LA BONNE MÉTHODE) logger.info( f" [COM] Méthode A : Article.FactoryArticleStock" ) # 1. Charger l'article COMPLET avec sa factory factory_article = self.cial.FactoryArticle persist_article_full = factory_article.ReadReference( article_ref ) article_full = win32com.client.CastTo( persist_article_full, "IBOArticle3" ) article_full.Read() # 2. Accéder à la FactoryArticleStock de l'article factory_article_stock = None try: factory_article_stock = ( article_full.FactoryArticleStock ) logger.info(" ✅ FactoryArticleStock trouvée") except AttributeError: logger.warning( " ❌ FactoryArticleStock non disponible" ) if factory_article_stock: # 3. Chercher si le stock existe déjà stock_trouve = None index_stock = 1 while index_stock <= 100: try: stock_persist = factory_article_stock.List( index_stock ) if stock_persist is None: break stock_obj = win32com.client.CastTo( stock_persist, "IBOArticleStock3" ) stock_obj.Read() # Vérifier le dépôt depot_stock = None try: depot_stock = getattr( stock_obj, "Depot", None ) if depot_stock: depot_stock.Read() depot_code = getattr( depot_stock, "DE_Code", "" ).strip() logger.debug( f" Dépôt {index_stock}: {depot_code}" ) # Si c'est le dépôt principal ou le premier trouvé if ( not stock_trouve or depot_code == getattr( depot_principal, "DE_Code", "", ) ): stock_trouve = stock_obj logger.info( f" Stock trouvé pour dépôt {depot_code}" ) except: pass index_stock += 1 except Exception as e: logger.debug( f" Erreur stock {index_stock}: {e}" ) index_stock += 1 # 4. Si pas trouvé, créer un nouveau stock if not stock_trouve: try: stock_persist = ( factory_article_stock.Create() ) stock_trouve = win32com.client.CastTo( stock_persist, "IBOArticleStock3" ) stock_trouve.SetDefault() # Lier au dépôt principal si disponible if depot_principal: try: stock_trouve.Depot = depot_principal logger.info( " Dépôt principal lié" ) except: pass logger.info(" Nouvel ArticleStock créé") except Exception as e: logger.error( f" ❌ Impossible de créer ArticleStock: {e}" ) raise # 5. METTRE À JOUR LES STOCKS MINI/MAXI if stock_trouve: # Sauvegarder l'état avant modification try: stock_trouve.Read() except: pass # STOCK MINI if stock_mini is not None: try: # Essayer différentes propriétés possibles for prop_name in [ "AS_QteMini", "AS_Mini", "AR_StockMini", "StockMini", ]: try: setattr( stock_trouve, prop_name, float(stock_mini), ) logger.info( f" ✅ Stock mini défini via {prop_name}: {stock_mini}" ) break except AttributeError: continue except Exception as e: logger.debug( f" {prop_name} échoué: {e}" ) continue except Exception as e: logger.warning( f" Stock mini non défini: {e}" ) # STOCK MAXI if stock_maxi is not None: try: # Essayer différentes propriétés possibles for prop_name in [ "AS_QteMaxi", "AS_Maxi", "AR_StockMaxi", "StockMaxi", ]: try: setattr( stock_trouve, prop_name, float(stock_maxi), ) logger.info( f" ✅ Stock maxi défini via {prop_name}: {stock_maxi}" ) break except AttributeError: continue except Exception as e: logger.debug( f" {prop_name} échoué: {e}" ) continue except Exception as e: logger.warning( f" Stock maxi non défini: {e}" ) # 6. SAUVEGARDER try: stock_trouve.Write() logger.info( f" ✅ ArticleStock sauvegardé" ) except Exception as e: logger.error( f" ❌ Erreur Write() ArticleStock: {e}" ) raise # MÉTHODE B : Alternative via DepotStock si A échoue if depot_principal and ( stock_mini is not None or stock_maxi is not None ): logger.info( f" [COM] Méthode B : Depot.FactoryDepotStock (alternative)" ) try: factory_depot_stock = None for factory_name in [ "FactoryDepotStock", "FactoryArticleStock", ]: try: factory_depot_stock = getattr( depot_principal, factory_name, None ) if factory_depot_stock: logger.info( f" Factory trouvée: {factory_name}" ) break except: continue if factory_depot_stock: # Chercher le stock existant stock_depot_trouve = None index_ds = 1 while index_ds <= 100: try: stock_ds_persist = ( factory_depot_stock.List( index_ds ) ) if stock_ds_persist is None: break stock_ds = win32com.client.CastTo( stock_ds_persist, "IBODepotStock3", ) stock_ds.Read() ar_ref_ds = ( getattr(stock_ds, "AR_Ref", "") .strip() .upper() ) if ar_ref_ds == article_ref: stock_depot_trouve = stock_ds break index_ds += 1 except: index_ds += 1 # Si pas trouvé, créer if not stock_depot_trouve: try: stock_ds_persist = ( factory_depot_stock.Create() ) stock_depot_trouve = ( win32com.client.CastTo( stock_ds_persist, "IBODepotStock3", ) ) stock_depot_trouve.SetDefault() stock_depot_trouve.AR_Ref = ( article_ref ) logger.info( " Nouveau DepotStock créé" ) except Exception as e: logger.error( f" ❌ Impossible de créer DepotStock: {e}" ) # Mettre à jour if stock_depot_trouve: if stock_mini is not None: try: stock_depot_trouve.AS_QteMini = float( stock_mini ) logger.info( f" ✅ DepotStock.AS_QteMini = {stock_mini}" ) except Exception as e: logger.warning( f" DepotStock mini échoué: {e}" ) if stock_maxi is not None: try: stock_depot_trouve.AS_QteMaxi = float( stock_maxi ) logger.info( f" ✅ DepotStock.AS_QteMaxi = {stock_maxi}" ) except Exception as e: logger.warning( f" DepotStock maxi échoué: {e}" ) try: stock_depot_trouve.Write() logger.info( " ✅ DepotStock sauvegardé" ) except Exception as e: logger.error( f" ❌ DepotStock Write() échoué: {e}" ) except Exception as e: logger.warning(f" Méthode B échouée: {e}") except Exception as e: logger.error( f"[STOCK] Erreur ajustement stock: {e}", exc_info=True, ) # Ne pas bloquer si l'ajustement échoue stocks_mis_a_jour.append( { "article_ref": article_ref, "quantite_ajoutee": quantite, "stock_mini_defini": stock_mini, "stock_maxi_defini": stock_maxi, } ) # ======================================== # ÉTAPE 5 : FINALISER LE DOCUMENT # ======================================== doc.Write() doc.Read() numero = getattr(doc, "DO_Piece", "") logger.info(f"[STOCK] ✅ Document finalisé: {numero}") # ======================================== # ÉTAPE 6 : VÉRIFICATION VIA COM # ======================================== logger.info(f"[STOCK] Vérification finale via COM...") for stock_info in stocks_mis_a_jour: article_ref = stock_info["article_ref"] try: # Recharger l'article pour voir les stocks persist_article = factory_article.ReadReference(article_ref) article_verif = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_verif.Read() # Lire les attributs de stock stock_total = 0.0 stock_mini_lu = 0.0 stock_maxi_lu = 0.0 # Essayer différents attributs for attr in ["AR_Stock", "AS_QteSto", "Stock"]: try: val = getattr(article_verif, attr, None) if val is not None: stock_total = float(val) break except: pass for attr in ["AR_StockMini", "AS_QteMini", "StockMini"]: try: val = getattr(article_verif, attr, None) if val is not None: stock_mini_lu = float(val) break except: pass for attr in ["AR_StockMaxi", "AS_QteMaxi", "StockMaxi"]: try: val = getattr(article_verif, attr, None) if val is not None: stock_maxi_lu = float(val) break except: pass logger.info( f"[VERIF] {article_ref}: " f"Total={stock_total}, " f"Mini={stock_mini_lu}, " f"Maxi={stock_maxi_lu}" ) stock_info["stock_total_verifie"] = stock_total stock_info["stock_mini_verifie"] = stock_mini_lu stock_info["stock_maxi_verifie"] = stock_maxi_lu except Exception as e: logger.warning( f"[VERIF] Erreur vérification {article_ref}: {e}" ) # Commit if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info(f"[STOCK] ✅ Transaction committée") except: logger.info(f"[STOCK] ✅ Changements sauvegardés") return { "article_ref": article_ref, "numero": numero, "type": 180, "type_libelle": "Entrée en stock", "date": str(getattr(doc, "DO_Date", "")), "nb_lignes": len(stocks_mis_a_jour), "stocks_mis_a_jour": stocks_mis_a_jour, } except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() logger.info(f"[STOCK] ❌ Transaction annulée") except: pass logger.error(f"[STOCK] ERREUR : {e}", exc_info=True) raise ValueError(f"Erreur création entrée stock : {str(e)}") except Exception as e: logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True) raise ValueError(f"Erreur création entrée stock : {str(e)}") def lire_stock_article(self, reference: str, calcul_complet: bool = False) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] Lecture stock : {reference}") # Charger l'article factory_article = self.cial.FactoryArticle persist_article = factory_article.ReadReference(reference.upper()) if not persist_article: raise ValueError(f"Article {reference} introuvable") article = win32com.client.CastTo(persist_article, "IBOArticle3") article.Read() ar_suivi = getattr(article, "AR_SuiviStock", 0) ar_design = getattr(article, "AR_Design", reference) stock_info = { "article": reference.upper(), "designation": ar_design, "stock_total": 0.0, "suivi_stock": ar_suivi, "suivi_libelle": { 0: "Aucun suivi", 1: "CMUP (sans lot)", 2: "FIFO/LIFO (avec lot)", }.get(ar_suivi, f"Code {ar_suivi}"), "depots": [], "methode_lecture": None, } # ======================================== # MÉTHODE 1 : Via Depot.FactoryDepotStock (RAPIDE - 1-2 sec) # ======================================== logger.info("[STOCK] Tentative Méthode 1 : Depot.FactoryDepotStock...") try: factory_depot = self.cial.FactoryDepot index_depot = 1 stocks_trouves = [] # OPTIMISATION : Limiter à 20 dépôts max (au lieu de 100) while index_depot <= 20: try: persist_depot = factory_depot.List(index_depot) if persist_depot is None: break depot = win32com.client.CastTo(persist_depot, "IBODepot3") depot.Read() depot_code = "" depot_intitule = "" try: depot_code = getattr(depot, "DE_Code", "").strip() depot_intitule = getattr( depot, "DE_Intitule", f"Dépôt {depot_code}" ) except: pass if not depot_code: index_depot += 1 continue # Chercher FactoryDepotStock factory_depot_stock = None for factory_name in [ "FactoryDepotStock", "FactoryArticleStock", ]: try: factory_depot_stock = getattr( depot, factory_name, None ) if factory_depot_stock: break except: pass if factory_depot_stock: # OPTIMISATION : Limiter le scan à 1000 stocks par dépôt index_stock = 1 while index_stock <= 1000: try: stock_persist = factory_depot_stock.List( index_stock ) if stock_persist is None: break stock = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) stock.Read() # Vérifier si c'est notre article article_ref_stock = "" # Essayer différents attributs for attr_ref in [ "AR_Ref", "AS_Article", "Article_Ref", ]: try: val = getattr(stock, attr_ref, None) if val: article_ref_stock = ( str(val).strip().upper() ) break except: pass # Si pas trouvé, essayer via l'objet Article if not article_ref_stock: try: article_obj = getattr( stock, "Article", None ) if article_obj: article_obj.Read() article_ref_stock = ( getattr( article_obj, "AR_Ref", "" ) .strip() .upper() ) except: pass if article_ref_stock == reference.upper(): # TROUVÉ ! quantite = 0.0 qte_mini = 0.0 qte_maxi = 0.0 # Essayer différents attributs de quantité for attr_qte in [ "AS_QteSto", "AS_Qte", "QteSto", "Quantite", ]: try: val = getattr(stock, attr_qte, None) if val is not None: quantite = float(val) break except: pass # Qte mini/maxi try: qte_mini = float( getattr(stock, "AS_QteMini", 0.0) ) except: pass try: qte_maxi = float( getattr(stock, "AS_QteMaxi", 0.0) ) except: pass stocks_trouves.append( { "code": depot_code, "intitule": depot_intitule, "quantite": quantite, "qte_mini": qte_mini, "qte_maxi": qte_maxi, } ) logger.info( f"[STOCK] Trouvé dépôt {depot_code} : {quantite} unités" ) break index_stock += 1 except Exception as e: if "Accès refusé" in str(e): break index_stock += 1 index_depot += 1 except Exception as e: if "Accès refusé" in str(e): break index_depot += 1 if stocks_trouves: stock_info["depots"] = stocks_trouves stock_info["stock_total"] = sum( d["quantite"] for d in stocks_trouves ) stock_info["methode_lecture"] = ( "Depot.FactoryDepotStock (RAPIDE)" ) logger.info( f"[STOCK] ✅ Méthode 1 réussie : {stock_info['stock_total']} unités" ) return stock_info except Exception as e: logger.warning(f"[STOCK] Méthode 1 échouée : {e}") # ======================================== # MÉTHODE 2 : Via attributs Article (RAPIDE - < 1 sec) # ======================================== logger.info("[STOCK] Tentative Méthode 2 : Attributs Article...") try: stock_trouve = False # Essayer différents attributs for attr_stock in ["AR_Stock", "AR_QteSto", "AR_QteStock"]: try: val = getattr(article, attr_stock, None) if val is not None: stock_info["stock_total"] = float(val) stock_info["methode_lecture"] = ( f"Article.{attr_stock} (RAPIDE)" ) stock_trouve = True logger.info( f"[STOCK] ✅ Méthode 2 réussie via {attr_stock}" ) break except: pass if stock_trouve: return stock_info except Exception as e: logger.warning(f"[STOCK] Méthode 2 échouée : {e}") # ======================================== # MÉTHODE 3 : Calcul depuis mouvements (LENT - DÉSACTIVÉ PAR DÉFAUT) # ======================================== if not calcul_complet: # Méthodes rapides ont échoué, mais calcul complet non demandé logger.warning( f"[STOCK] ⚠️ Méthodes rapides échouées pour {reference}" ) stock_info["methode_lecture"] = "ÉCHEC - Méthodes rapides échouées" stock_info["stock_total"] = 0.0 stock_info["note"] = ( "Les méthodes rapides de lecture de stock ont échoué. " "Options disponibles :\n" "1. Utiliser GET /sage/diagnostic/stock-rapide-test/{reference} pour un diagnostic détaillé\n" "2. Appeler lire_stock_article(reference, calcul_complet=True) pour un calcul depuis mouvements (LENT - 20+ min)\n" "3. Vérifier que l'article a bien un suivi de stock activé (AR_SuiviStock)" ) return stock_info # ⚠️ ATTENTION : Cette partie est ULTRA-LENTE (20+ minutes) logger.warning( "[STOCK] ⚠️ CALCUL DEPUIS MOUVEMENTS ACTIVÉ - PEUT PRENDRE 20+ MINUTES" ) # [Le reste du code de calcul depuis mouvements reste inchangé...] # ... (code existant pour la méthode 3) except ValueError: raise except Exception as e: logger.error(f"[STOCK] Erreur lecture : {e}", exc_info=True) raise ValueError(f"Erreur lecture stock : {str(e)}") def verifier_stock_apres_mouvement( self, article_ref: str, numero_mouvement: str ) -> Dict: try: with self._com_context(), self._lock_com: logger.info( f"[DEBUG] Vérification mouvement {numero_mouvement} pour {article_ref}" ) diagnostic = { "article_ref": article_ref.upper(), "numero_mouvement": numero_mouvement, "mouvement_trouve": False, "ar_ref_dans_ligne": None, "quantite_ligne": 0, "stock_actuel": 0, "problemes": [], } # ======================================== # 1. VÉRIFIER LE DOCUMENT # ======================================== factory = self.cial.FactoryDocumentStock persist = None index = 1 while index < 10000: try: persist_test = factory.List(index) if persist_test is None: break doc_test = win32com.client.CastTo( persist_test, "IBODocumentStock3" ) doc_test.Read() if getattr(doc_test, "DO_Piece", "") == numero_mouvement: persist = persist_test diagnostic["mouvement_trouve"] = True break index += 1 except: index += 1 if not persist: diagnostic["problemes"].append( f"Document {numero_mouvement} introuvable" ) return diagnostic doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.Read() # ======================================== # 2. VÉRIFIER LES LIGNES # ======================================== try: factory_lignes = getattr(doc, "FactoryDocumentLigne", None) if not factory_lignes: factory_lignes = getattr(doc, "FactoryDocumentStockLigne", None) if factory_lignes: idx = 1 while idx <= 100: try: ligne_p = factory_lignes.List(idx) if ligne_p is None: break ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) ligne.Read() ar_ref_ligne = getattr(ligne, "AR_Ref", "").strip() if ar_ref_ligne == article_ref.upper(): diagnostic["ar_ref_dans_ligne"] = ar_ref_ligne diagnostic["quantite_ligne"] = float( getattr(ligne, "DL_Qte", 0) ) break idx += 1 except: idx += 1 except Exception as e: diagnostic["problemes"].append(f"Erreur lecture lignes : {e}") if not diagnostic["ar_ref_dans_ligne"]: diagnostic["problemes"].append( f"AR_Ref '{article_ref}' non trouvé dans les lignes du mouvement. " f"L'article n'a pas été correctement lié." ) # ======================================== # 3. LIRE LE STOCK ACTUEL # ======================================== try: stock_info = self.lire_stock_article(article_ref) diagnostic["stock_actuel"] = stock_info["stock_total"] except: diagnostic["problemes"].append("Impossible de lire le stock actuel") # ======================================== # 4. ANALYSE # ======================================== if diagnostic["ar_ref_dans_ligne"] and diagnostic["stock_actuel"] == 0: diagnostic["problemes"].append( "PROBLÈME : L'article est dans la ligne du mouvement, " "mais le stock n'a pas été mis à jour. Cela indique un problème " "avec la méthode SetDefaultArticle() ou la configuration Sage." ) return diagnostic except Exception as e: logger.error(f"[DEBUG] Erreur : {e}", exc_info=True) raise """ 📦 Lit le stock d'un article - VERSION CORRIGÉE ✅ CORRECTIONS : 1. Cherche d'abord via ArticleStock 2. Puis via DepotStock si disponible 3. Calcule le total même si aucun dépôt n'est trouvé """ try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] Lecture stock article : {reference}") factory_article = self.cial.FactoryArticle persist = factory_article.ReadReference(reference.upper()) if not persist: raise ValueError(f"Article {reference} introuvable") article = win32com.client.CastTo(persist, "IBOArticle3") article.Read() # Infos de base ar_suivi = getattr(article, "AR_SuiviStock", 0) suivi_libelles = { 0: "Aucun", 1: "CMUP (sans lot)", 2: "FIFO/LIFO (avec lot)", } stock_info = { "article": getattr(article, "AR_Ref", "").strip(), "designation": getattr(article, "AR_Design", ""), "stock_total": 0.0, "suivi_stock": ar_suivi, "suivi_libelle": suivi_libelles.get(ar_suivi, "Inconnu"), "depots": [], } # ======================================== # MÉTHODE 1 : Via ArticleStock (global) # ======================================== stock_global_trouve = False try: # Chercher dans ArticleStock (collection sur l'article) if hasattr(article, "ArticleStock"): article_stocks = article.ArticleStock if article_stocks: try: nb_stocks = article_stocks.Count logger.info(f" ArticleStock.Count = {nb_stocks}") for i in range(1, nb_stocks + 1): try: stock_item = article_stocks.Item(i) qte = float( getattr(stock_item, "AS_QteSto", 0.0) ) stock_info["stock_total"] += qte depot_code = "?" try: depot_obj = getattr( stock_item, "Depot", None ) if depot_obj: depot_obj.Read() depot_code = getattr( depot_obj, "DE_Code", "?" ) except: pass stock_info["depots"].append( { "code": depot_code, "quantite": qte, "qte_mini": float( getattr( stock_item, "AS_QteMini", 0.0 ) ), "qte_maxi": float( getattr( stock_item, "AS_QteMaxi", 0.0 ) ), } ) stock_global_trouve = True except: continue except: pass except: pass # ======================================== # MÉTHODE 2 : Via FactoryDepotStock (si méthode 1 échoue) # ======================================== if not stock_global_trouve: logger.info( " ArticleStock non disponible, essai FactoryDepotStock..." ) try: factory_depot = self.cial.FactoryDepot # Scanner tous les dépôts index_depot = 1 while index_depot <= 100: try: persist_depot = factory_depot.List(index_depot) if persist_depot is None: break depot_obj = win32com.client.CastTo( persist_depot, "IBODepot3" ) depot_obj.Read() depot_code = getattr(depot_obj, "DE_Code", "").strip() # Chercher le stock dans ce dépôt try: factory_depot_stock = getattr( depot_obj, "FactoryDepotStock", None ) if factory_depot_stock: index_stock = 1 while index_stock <= 1000: try: stock_persist = ( factory_depot_stock.List( index_stock ) ) if stock_persist is None: break stock = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) stock.Read() ar_ref_stock = getattr( stock, "AR_Ref", "" ).strip() if ar_ref_stock == reference.upper(): qte = float( getattr(stock, "AS_QteSto", 0.0) ) stock_info["stock_total"] += qte stock_info["depots"].append( { "code": depot_code, "quantite": qte, "qte_mini": float( getattr( stock, "AS_QteMini", 0.0, ) ), "qte_maxi": float( getattr( stock, "AS_QteMaxi", 0.0, ) ), } ) break index_stock += 1 except: index_stock += 1 except: pass index_depot += 1 except: index_depot += 1 except: pass # ======================================== # RÉSULTAT FINAL # ======================================== if not stock_info["depots"]: logger.warning(f"[STOCK] {reference} : Aucun stock trouvé") else: logger.info( f"[STOCK] {reference} : {stock_info['stock_total']} unités dans {len(stock_info['depots'])} dépôt(s)" ) return stock_info except Exception as e: logger.error(f"[STOCK] Erreur lecture : {e}", exc_info=True) raise def creer_sortie_stock(self, sortie_data: Dict) -> Dict: try: with self._com_context(), self._lock_com: logger.info(f"[STOCK] === CRÉATION SORTIE STOCK ===") logger.info(f"[STOCK] {len(sortie_data.get('lignes', []))} ligne(s)") try: self.cial.CptaApplication.BeginTrans() except: pass try: # ======================================== # ÉTAPE 1 : CRÉER LE DOCUMENT # ======================================== factory = self.cial.FactoryDocumentStock persist = factory.CreateType(181) # 181 = Sortie doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.SetDefault() # Date import pywintypes date_mouv = sortie_data.get("date_mouvement") if isinstance(date_mouv, date): doc.DO_Date = pywintypes.Time( datetime.combine(date_mouv, datetime.min.time()) ) else: doc.DO_Date = pywintypes.Time(datetime.now()) # Référence if sortie_data.get("reference"): doc.DO_Ref = sortie_data["reference"] doc.Write() logger.info( f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}" ) # ======================================== # ÉTAPE 2 : FACTORY LIGNES # ======================================== try: factory_lignes = doc.FactoryDocumentLigne except: factory_lignes = doc.FactoryDocumentStockLigne factory_article = self.cial.FactoryArticle stocks_mis_a_jour = [] # ======================================== # ÉTAPE 3 : TRAITER CHAQUE LIGNE # ======================================== for idx, ligne_data in enumerate(sortie_data["lignes"], 1): article_ref = ligne_data["article_ref"].upper() quantite = ligne_data["quantite"] logger.info( f"[STOCK] ======== LIGNE {idx} : {article_ref} x {quantite} ========" ) # Charger l'article persist_article = factory_article.ReadReference(article_ref) if not persist_article: raise ValueError(f"Article {article_ref} introuvable") article_obj = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_obj.Read() ar_suivi = getattr(article_obj, "AR_SuiviStock", 0) ar_design = getattr(article_obj, "AR_Design", article_ref) logger.info(f"[STOCK] Article : {ar_design}") logger.info(f"[STOCK] AR_SuiviStock : {ar_suivi}") # ⚠️ VÉRIFIER LE STOCK DISPONIBLE stock_dispo = self.verifier_stock_suffisant( article_ref, quantite, None ) if not stock_dispo["suffisant"]: raise ValueError( f"Stock insuffisant pour {article_ref} : " f"disponible={stock_dispo['stock_disponible']}, " f"demandé={quantite}" ) logger.info( f"[STOCK] Stock disponible : {stock_dispo['stock_disponible']}" ) # Gérer le lot numero_lot = ligne_data.get("numero_lot") if ar_suivi == 1: # CMUP if numero_lot: logger.warning(f"[STOCK] CMUP : Suppression du lot") numero_lot = None elif ar_suivi == 2: # FIFO/LIFO if not numero_lot: import uuid numero_lot = f"AUTO-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6].upper()}" logger.info( f"[STOCK] FIFO/LIFO : Lot auto-généré '{numero_lot}'" ) # ======================================== # CRÉER LA LIGNE # ======================================== ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentStockLigne3" ) ligne_obj.SetDefault() # ======================================== # LIAISON ARTICLE # ======================================== article_lie = False try: ligne_obj.SetDefaultArticleReference( article_ref, float(quantite) ) article_lie = True logger.info(f"[STOCK] ✅ SetDefaultArticleReference()") except: try: ligne_obj.SetDefaultArticle( article_obj, float(quantite) ) article_lie = True logger.info(f"[STOCK] ✅ SetDefaultArticle()") except: pass if not article_lie: raise ValueError( f"Impossible de lier l'article {article_ref}" ) # ======================================== # LOT (si FIFO/LIFO) # ======================================== if numero_lot and ar_suivi == 2: try: ligne_obj.SetDefaultLot(numero_lot) logger.info(f"[STOCK] ✅ Lot défini") except: try: ligne_obj.LS_NoSerie = numero_lot logger.info(f"[STOCK] ✅ Lot via LS_NoSerie") except: pass # Prix prix = ligne_data.get("prix_unitaire") if prix: try: ligne_obj.DL_PrixUnitaire = float(prix) except: pass # ======================================== # ÉCRIRE LA LIGNE # ======================================== ligne_obj.Write() logger.info(f"[STOCK] ✅ Write() réussi") # Vérification ligne_obj.Read() ref_verifiee = article_ref # Supposer OK si Write() réussi try: article_lie_obj = getattr(ligne_obj, "Article", None) if article_lie_obj: article_lie_obj.Read() ref_verifiee = ( getattr(article_lie_obj, "AR_Ref", "").strip() or article_ref ) except: pass logger.info(f"[STOCK] ✅ LIGNE {idx} COMPLÈTE") stocks_mis_a_jour.append( { "article_ref": article_ref, "quantite_retiree": quantite, "reference_verifiee": ref_verifiee, "stock_avant": stock_dispo["stock_disponible"], "stock_apres": stock_dispo["stock_apres"], "numero_lot": numero_lot if ar_suivi == 2 else None, } ) # ======================================== # FINALISER # ======================================== doc.Write() doc.Read() numero = getattr(doc, "DO_Piece", "") logger.info(f"[STOCK] ✅ Document finalisé : {numero}") # Commit try: self.cial.CptaApplication.CommitTrans() logger.info(f"[STOCK] ✅ Transaction committée") except: pass return { "numero": numero, "type": 1, "date": str(getattr(doc, "DO_Date", "")), "nb_lignes": len(stocks_mis_a_jour), "reference": sortie_data.get("reference"), "stocks_mis_a_jour": stocks_mis_a_jour, } except Exception as e: logger.error(f"[STOCK] ERREUR : {e}", exc_info=True) try: self.cial.CptaApplication.RollbackTrans() except: pass raise ValueError(f"Erreur création sortie stock : {str(e)}") except Exception as e: logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True) raise ValueError(f"Erreur création sortie stock : {str(e)}") def lire_mouvement_stock(self, numero: str) -> Dict: try: with self._com_context(), self._lock_com: factory = self.cial.FactoryDocumentStock # Chercher le document persist = None index = 1 logger.info(f"[MOUVEMENT] Recherche de {numero}...") while index < 10000: try: persist_test = factory.List(index) if persist_test is None: break doc_test = win32com.client.CastTo( persist_test, "IBODocumentStock3" ) doc_test.Read() if getattr(doc_test, "DO_Piece", "") == numero: persist = persist_test logger.info(f"[MOUVEMENT] Trouvé à l'index {index}") break index += 1 except: index += 1 if not persist: raise ValueError(f"Mouvement {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.Read() # Infos du document do_type = getattr(doc, "DO_Type", -1) types_mouvements = { 180: "Entrée", 181: "Sortie", 182: "Transfert", 183: "Inventaire", } mouvement = { "numero": numero, "type": do_type, "type_libelle": types_mouvements.get(do_type, f"Type {do_type}"), "date": str(getattr(doc, "DO_Date", "")), "reference": getattr(doc, "DO_Ref", ""), "lignes": [], } # Lire les lignes try: factory_lignes = getattr( doc, "FactoryDocumentLigne", None ) or getattr(doc, "FactoryDocumentStockLigne", None) if factory_lignes: idx = 1 while idx <= 100: try: ligne_p = factory_lignes.List(idx) if ligne_p is None: break try: ligne = win32com.client.CastTo( ligne_p, "IBODocumentLigne3" ) except: ligne = win32com.client.CastTo( ligne_p, "IBODocumentStockLigne3" ) ligne.Read() # Récupérer la référence article via l'objet Article article_ref = "" try: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() article_ref = getattr( article_obj, "AR_Ref", "" ).strip() except: pass ligne_info = { "article_ref": 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) ), "numero_lot": getattr(ligne, "LS_NoSerie", ""), } mouvement["lignes"].append(ligne_info) idx += 1 except: break except Exception as e: logger.warning(f"[MOUVEMENT] Erreur lecture lignes : {e}") mouvement["nb_lignes"] = len(mouvement["lignes"]) logger.info( f"[MOUVEMENT] Lu : {numero} - {mouvement['nb_lignes']} ligne(s)" ) return mouvement except ValueError: raise except Exception as e: logger.error(f"[MOUVEMENT] Erreur : {e}", exc_info=True) raise ValueError(f"Erreur lecture mouvement : {str(e)}") def verifier_stock_suffisant(self, article_ref, quantite, depot=None): """Version thread-safe avec lock SQL""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ✅ LOCK pour éviter les race conditions cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") cursor.execute("BEGIN TRANSACTION") try: # Lire stock avec lock cursor.execute( """ SELECT SUM(AS_QteSto) FROM F_ARTSTOCK WITH (UPDLOCK, ROWLOCK) WHERE AR_Ref = ? """, (article_ref.upper(),) ) row = cursor.fetchone() stock_dispo = float(row[0]) if row and row[0] else 0.0 suffisant = stock_dispo >= quantite cursor.execute("COMMIT") return { "suffisant": suffisant, "stock_disponible": stock_dispo, "quantite_demandee": quantite } except: cursor.execute("ROLLBACK") raise except Exception as e: logger.error(f"Erreur vérification stock: {e}") raise def lister_modeles_crystal(self) -> Dict: """ 📋 Liste les modèles en scannant le répertoire Sage ✅ FONCTIONNE MÊME SI FactoryEtat N'EXISTE PAS """ try: logger.info("[MODELES] Scan du répertoire des modèles...") # Chemin typique des modèles Sage 100c # Adapter selon votre installation chemins_possibles = [ r"C:\Users\Public\Documents\Sage\Entreprise 100c\fr-FR\Documents standards\Gestion commerciale\Ventes", ] # Essayer de détecter depuis la base Sage chemin_base = self.chemin_base if chemin_base: # Extraire le répertoire Sage import os dossier_sage = os.path.dirname(os.path.dirname(chemin_base)) chemins_possibles.insert(0, os.path.join(dossier_sage, "Modeles")) modeles_par_type = { "devis": [], "commandes": [], "livraisons": [], "factures": [], "avoirs": [], "autres": [] } # Scanner les répertoires import os import glob for chemin in chemins_possibles: if not os.path.exists(chemin): continue logger.info(f"[MODELES] Scan: {chemin}") # Chercher tous les fichiers .RPT et .BGC for pattern in ["*.RPT", "*.rpt", "*.BGC", "*.bgc"]: fichiers = glob.glob(os.path.join(chemin, pattern)) for fichier in fichiers: nom_fichier = os.path.basename(fichier) # Déterminer la catégorie categorie = "autres" nom_upper = nom_fichier.upper() if "DEVIS" in nom_upper or nom_upper.startswith("VT_DE"): categorie = "devis" elif "CMDE" in nom_upper or "COMMANDE" in nom_upper or nom_upper.startswith("VT_BC"): categorie = "commandes" elif nom_upper.startswith("VT_BL") or "LIVRAISON" in nom_upper: categorie = "livraisons" elif "FACT" in nom_upper or nom_upper.startswith("VT_FA"): categorie = "factures" elif "AVOIR" in nom_upper or nom_upper.startswith("VT_AV"): categorie = "avoirs" modeles_par_type[categorie].append({ "fichier": nom_fichier, "nom": nom_fichier.replace(".RPT", "").replace(".rpt", "").replace(".BGC", "").replace(".bgc", ""), "chemin_complet": fichier }) # Si on a trouvé des fichiers, pas besoin de continuer if any(len(v) > 0 for v in modeles_par_type.values()): break total = sum(len(v) for v in modeles_par_type.values()) logger.info(f"[MODELES] {total} modèles trouvés") return modeles_par_type except Exception as e: logger.error(f"[MODELES] Erreur: {e}", exc_info=True) raise RuntimeError(f"Erreur listage modèles: {str(e)}") def _detecter_methodes_impression(self, doc) -> dict: """🔍 Détecte les méthodes d'impression disponibles""" methodes = {} # Tester FactoryEtat try: factory_etat = self.cial.CptaApplication.FactoryEtat if factory_etat: methodes["FactoryEtat"] = True except: try: factory_etat = self.cial.FactoryEtat if factory_etat: methodes["FactoryEtat"] = True except: pass # Tester Imprimer() if hasattr(doc, "Imprimer"): methodes["Imprimer"] = True # Tester Print() if hasattr(doc, "Print"): methodes["Print"] = True # Tester ExportPDF() if hasattr(doc, "ExportPDF"): methodes["ExportPDF"] = True return methodes def generer_pdf_document( self, numero: str, type_doc: int, modele: str = None ) -> bytes: """ 📄 Génération PDF Sage 100c - UTILISE LES .BGC DIRECTEMENT Args: numero: Numéro document (ex: "FA00123") type_doc: Type Sage (0=devis, 60=facture, etc.) modele: Nom fichier .bgc (optionnel) Returns: bytes: Contenu PDF """ if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info(f"[PDF] === GÉNÉRATION PDF AVEC .BGC ===") logger.info(f"[PDF] Document: {numero} (type={type_doc})") # ======================================== # 1. CHARGER LE DOCUMENT SAGE # ======================================== factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(type_doc, numero) if not persist: persist = self._find_document_in_list(numero, type_doc) if not persist: raise ValueError(f"Document {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() logger.info(f"[PDF] ✅ Document chargé") # ======================================== # 2. DÉTERMINER LE MODÈLE .BGC # ======================================== chemin_modele = self._determiner_modele(type_doc, modele) logger.info(f"[PDF] 📄 Modèle: {os.path.basename(chemin_modele)}") logger.info(f"[PDF] 📁 Chemin: {chemin_modele}") # ======================================== # 3. VÉRIFIER QUE LE FICHIER EXISTE # ======================================== import os if not os.path.exists(chemin_modele): raise ValueError(f"Modèle introuvable: {chemin_modele}") # ======================================== # 4. CRÉER FICHIER TEMPORAIRE # ======================================== import tempfile import time temp_dir = tempfile.gettempdir() pdf_path = os.path.join(temp_dir, f"sage_{numero}_{int(time.time())}.pdf") pdf_bytes = None # ======================================== # MÉTHODE 1 : Crystal Reports Runtime (PRIORITAIRE) # ======================================== logger.info("[PDF] 🔷 Méthode 1: Crystal Reports Runtime...") try: pdf_bytes = self._generer_pdf_crystal_runtime( numero, type_doc, chemin_modele, pdf_path ) if pdf_bytes: logger.info("[PDF] ✅ Méthode 1 réussie (Crystal Runtime)") except Exception as e: logger.warning(f"[PDF] Méthode 1 échouée: {e}") # ======================================== # MÉTHODE 2 : Crystal via DLL Sage # ======================================== if not pdf_bytes: logger.info("[PDF] 🔷 Méthode 2: Crystal via DLL Sage...") try: pdf_bytes = self._generer_pdf_crystal_sage_dll( numero, type_doc, chemin_modele, pdf_path ) if pdf_bytes: logger.info("[PDF] ✅ Méthode 2 réussie (DLL Sage)") except Exception as e: logger.warning(f"[PDF] Méthode 2 échouée: {e}") # ======================================== # MÉTHODE 3 : Sage Reports Viewer (si installé) # ======================================== if not pdf_bytes: logger.info("[PDF] 🔷 Méthode 3: Sage Reports Viewer...") try: pdf_bytes = self._generer_pdf_sage_viewer( numero, type_doc, chemin_modele, pdf_path ) if pdf_bytes: logger.info("[PDF] ✅ Méthode 3 réussie (Sage Viewer)") except Exception as e: logger.warning(f"[PDF] Méthode 3 échouée: {e}") # ======================================== # MÉTHODE 4 : Python reportlab (FALLBACK) # ======================================== if not pdf_bytes: logger.warning("[PDF] ⚠️ TOUTES LES MÉTHODES .BGC ONT ÉCHOUÉ") logger.info("[PDF] 🔷 Méthode 4: PDF Custom (fallback)...") try: pdf_bytes = self._generer_pdf_custom(doc, numero, type_doc) if pdf_bytes: logger.info("[PDF] ✅ Méthode 4 réussie (PDF custom)") except Exception as e: logger.error(f"[PDF] Méthode 4 échouée: {e}") # ======================================== # VALIDATION & NETTOYAGE # ======================================== try: if os.path.exists(pdf_path): os.remove(pdf_path) except: pass if not pdf_bytes: raise RuntimeError( "❌ ÉCHEC GÉNÉRATION PDF\n\n" "🔍 DIAGNOSTIC:\n" f"- Modèle .bgc trouvé: ✅ ({os.path.basename(chemin_modele)})\n" f"- Crystal Reports installé: ❌ NON DÉTECTÉ\n\n" "💡 SOLUTIONS:\n" "1. Installer SAP Crystal Reports Runtime (gratuit):\n" " https://www.sap.com/products/technology-platform/crystal-reports/trial.html\n" " Choisir: Crystal Reports Runtime (64-bit)\n\n" "2. OU installer depuis DVD Sage 100c:\n" " Composants/Crystal Reports Runtime\n\n" "3. Vérifier que le service 'Crystal Reports' est démarré:\n" " services.msc → SAP Crystal Reports Processing Server\n\n" "4. En attendant, utiliser /pdf-custom pour un PDF simple" ) if len(pdf_bytes) < 500: raise RuntimeError("PDF généré trop petit (probablement corrompu)") logger.info(f"[PDF] ✅✅✅ SUCCÈS: {len(pdf_bytes):,} octets") return pdf_bytes except ValueError as e: logger.error(f"[PDF] Erreur métier: {e}") raise except Exception as e: logger.error(f"[PDF] Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur PDF: {str(e)}") def _generer_pdf_crystal_runtime(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 1: Crystal Reports Runtime API""" try: import os # Essayer différentes ProgID Crystal Reports prog_ids_crystal = [ "CrystalRuntime.Application.140", # Crystal Reports 2020 "CrystalRuntime.Application.13", # Crystal Reports 2016 "CrystalRuntime.Application.12", # Crystal Reports 2013 "CrystalRuntime.Application.11", # Crystal Reports 2011 "CrystalRuntime.Application", # Générique "CrystalDesignRunTime.Application", # Alternative ] crystal = None prog_id_utilisee = None for prog_id in prog_ids_crystal: try: crystal = win32com.client.Dispatch(prog_id) prog_id_utilisee = prog_id logger.info(f" ✅ Crystal trouvé: {prog_id}") break except Exception as e: logger.debug(f" {prog_id}: {e}") continue if not crystal: logger.info(" ❌ Aucune ProgID Crystal trouvée") return None # Ouvrir le rapport .bgc logger.info(f" 📂 Ouverture: {os.path.basename(chemin_modele)}") rapport = crystal.OpenReport(chemin_modele) # Configurer la connexion SQL logger.info(" 🔌 Configuration connexion SQL...") for table in rapport.Database.Tables: try: # Méthode 1: SetDataSource table.SetDataSource( self.sql_server, self.sql_database, "", "" ) except: try: # Méthode 2: ConnectionProperties table.ConnectionProperties.Item["Server Name"] = self.sql_server table.ConnectionProperties.Item["Database Name"] = self.sql_database table.ConnectionProperties.Item["Integrated Security"] = True except: pass # Appliquer le filtre Crystal Reports logger.info(f" 🔍 Filtre: DO_Piece = '{numero}'") rapport.RecordSelectionFormula = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'" # Exporter en PDF logger.info(" 📄 Export PDF...") rapport.ExportOptions.DestinationType = 1 # crEDTDiskFile rapport.ExportOptions.FormatType = 31 # crEFTPortableDocFormat (PDF) rapport.ExportOptions.DiskFileName = pdf_path rapport.Export(False) # Attendre la création du fichier import time max_wait = 30 waited = 0 while not os.path.exists(pdf_path) and waited < max_wait: time.sleep(0.5) waited += 0.5 if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: with open(pdf_path, 'rb') as f: return f.read() logger.warning(" ⚠️ Fichier PDF non créé") return None except Exception as e: logger.debug(f" Crystal Runtime: {e}") return None def _generer_pdf_crystal_sage_dll(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 2: Utiliser les DLL Crystal de Sage directement""" try: import os import ctypes # Chercher les DLL Crystal dans le dossier Sage dossier_sage = os.path.dirname(os.path.dirname(self.chemin_base)) chemins_dll = [ os.path.join(dossier_sage, "CrystalReports", "crpe32.dll"), os.path.join(dossier_sage, "Crystal", "crpe32.dll"), r"C:\Program Files (x86)\SAP BusinessObjects\Crystal Reports for .NET Framework 4.0\Common\SAP BusinessObjects Enterprise XI 4.0\win64_x64\crpe32.dll", ] dll_trouvee = None for chemin_dll in chemins_dll: if os.path.exists(chemin_dll): dll_trouvee = chemin_dll break if not dll_trouvee: logger.info(" ❌ DLL Crystal Sage non trouvée") return None logger.info(f" ✅ DLL trouvée: {dll_trouvee}") # Charger la DLL crpe = ctypes.cdll.LoadLibrary(dll_trouvee) # Ouvrir le rapport (API C Crystal Reports) # Note: Ceci est une approche bas niveau, peut nécessiter des ajustements job_handle = crpe.PEOpenPrintJob(chemin_modele.encode()) if job_handle == 0: logger.warning(" ⚠️ Impossible d'ouvrir le rapport") return None # Définir les paramètres de connexion # ... (code simplifié, nécessiterait plus de configuration) # Exporter crpe.PEExportTo(job_handle, pdf_path.encode(), 31) # 31 = PDF # Fermer crpe.PEClosePrintJob(job_handle) import time time.sleep(2) if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: with open(pdf_path, 'rb') as f: return f.read() return None except Exception as e: logger.debug(f" DLL Sage: {e}") return None def _generer_pdf_sage_viewer(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 3: Sage Reports Viewer (si installé)""" try: import os # Chercher l'exécutable Sage Reports executables_possibles = [ r"C:\Program Files\Sage\Reports\SageReports.exe", r"C:\Program Files (x86)\Sage\Reports\SageReports.exe", os.path.join( os.path.dirname(os.path.dirname(self.chemin_base)), "Reports", "SageReports.exe" ) ] exe_trouve = None for exe in executables_possibles: if os.path.exists(exe): exe_trouve = exe break if not exe_trouve: logger.info(" ❌ SageReports.exe non trouvé") return None logger.info(f" ✅ SageReports trouvé: {exe_trouve}") # Lancer en ligne de commande avec paramètres import subprocess cmd = [ exe_trouve, "/report", chemin_modele, "/export", pdf_path, "/format", "PDF", "/filter", f"DO_Piece='{numero}'", "/silent" ] logger.info(" 🚀 Lancement SageReports...") result = subprocess.run(cmd, capture_output=True, timeout=30) import time time.sleep(2) if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: with open(pdf_path, 'rb') as f: return f.read() logger.warning(" ⚠️ PDF non généré par SageReports") return None except Exception as e: logger.debug(f" Sage Viewer: {e}") return None def _generer_pdf_custom(self, doc, numero, type_doc): """🎨 Génère un PDF simple avec les données du document (FALLBACK)""" try: from reportlab.lib.pagesizes import A4 from reportlab.lib.units import cm from reportlab.pdfgen import canvas from reportlab.lib import colors from io import BytesIO buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 # En-tête pdf.setFont("Helvetica-Bold", 20) type_libelles = {0: "DEVIS", 10: "BON DE COMMANDE", 30: "BON DE LIVRAISON", 60: "FACTURE", 50: "AVOIR"} type_libelle = type_libelles.get(type_doc, "DOCUMENT") pdf.drawString(2*cm, height - 3*cm, type_libelle) # Numéro pdf.setFont("Helvetica", 12) pdf.drawString(2*cm, height - 4*cm, f"Numéro: {numero}") # Date date_doc = getattr(doc, "DO_Date", "") pdf.drawString(2*cm, height - 4.5*cm, f"Date: {date_doc}") # Client try: client = getattr(doc, "Client", None) if client: client.Read() client_nom = getattr(client, "CT_Intitule", "") pdf.drawString(2*cm, height - 5.5*cm, f"Client: {client_nom}") except: pass # Ligne séparatrice pdf.line(2*cm, height - 6*cm, width - 2*cm, height - 6*cm) # Lignes du document y_pos = height - 7*cm pdf.setFont("Helvetica-Bold", 10) pdf.drawString(2*cm, y_pos, "Article") pdf.drawString(8*cm, y_pos, "Qté") pdf.drawString(11*cm, y_pos, "Prix U.") pdf.drawString(15*cm, y_pos, "Total") y_pos -= 0.5*cm pdf.line(2*cm, y_pos, width - 2*cm, y_pos) # Lire lignes pdf.setFont("Helvetica", 9) try: factory_lignes = getattr(doc, "FactoryDocumentLigne", None) if factory_lignes: idx = 1 while idx <= 50 and y_pos > 5*cm: try: ligne_p = factory_lignes.List(idx) if ligne_p is None: break ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() y_pos -= 0.7*cm design = getattr(ligne, "DL_Design", "")[:40] qte = float(getattr(ligne, "DL_Qte", 0)) prix = float(getattr(ligne, "DL_PrixUnitaire", 0)) total = float(getattr(ligne, "DL_MontantHT", 0)) pdf.drawString(2*cm, y_pos, design) pdf.drawString(8*cm, y_pos, f"{qte:.2f}") pdf.drawString(11*cm, y_pos, f"{prix:.2f}€") pdf.drawString(15*cm, y_pos, f"{total:.2f}€") idx += 1 except: break except: pass # Totaux y_pos = 5*cm pdf.line(2*cm, y_pos, width - 2*cm, y_pos) y_pos -= 0.7*cm pdf.setFont("Helvetica-Bold", 11) total_ht = float(getattr(doc, "DO_TotalHT", 0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0)) pdf.drawString(13*cm, y_pos, f"Total HT:") pdf.drawString(16*cm, y_pos, f"{total_ht:.2f}€") y_pos -= 0.7*cm pdf.drawString(13*cm, y_pos, f"TVA:") pdf.drawString(16*cm, y_pos, f"{(total_ttc - total_ht):.2f}€") y_pos -= 0.7*cm pdf.setFont("Helvetica-Bold", 14) pdf.drawString(13*cm, y_pos, f"Total TTC:") pdf.drawString(16*cm, y_pos, f"{total_ttc:.2f}€") # Pied de page pdf.setFont("Helvetica", 8) pdf.drawString(2*cm, 2*cm, "PDF généré par l'API Sage - Version simplifiée") pdf.drawString(2*cm, 1.5*cm, "Pour un rendu Crystal Reports complet, installez SAP BusinessObjects") pdf.showPage() pdf.save() return buffer.getvalue() except Exception as e: logger.error(f"PDF custom: {e}") return None def _determiner_modele(self, type_doc: int, modele_demande: str = None) -> str: """ 🔍 Détermine le chemin du modèle Crystal Reports à utiliser Args: type_doc: Type Sage (0=devis, 60=facture, etc.) modele_demande: Nom fichier .bgc spécifique (optionnel) Returns: str: Chemin complet du modèle """ if modele_demande: # Modèle spécifié modeles_dispo = self.lister_modeles_crystal() for categorie, liste in modeles_dispo.items(): for m in liste: if m["fichier"].lower() == modele_demande.lower(): return m["chemin_complet"] raise ValueError(f"Modèle '{modele_demande}' introuvable") # Modèle par défaut selon type modeles = self.lister_modeles_crystal() mapping = { 0: "devis", 10: "commandes", 30: "livraisons", 60: "factures", 50: "avoirs" } categorie = mapping.get(type_doc) if not categorie or categorie not in modeles: raise ValueError(f"Aucun modèle disponible pour type {type_doc}") liste = modeles[categorie] if not liste: raise ValueError(f"Aucun modèle {categorie} trouvé") # Prioriser modèle "standard" (sans FlyDoc, email, etc.) modele_std = next( (m for m in liste if "flydoc" not in m["fichier"].lower() and "email" not in m["fichier"].lower()), liste[0] ) return modele_std["chemin_complet"] def diagnostiquer_impression_approfondi(self): """🔬 Diagnostic ultra-complet pour trouver les objets d'impression""" try: with self._com_context(), self._lock_com: logger.info("=" * 80) logger.info("DIAGNOSTIC IMPRESSION APPROFONDI") logger.info("=" * 80) objets_a_tester = [ ("self.cial", self.cial), ("CptaApplication", self.cial.CptaApplication), ] # Charger un document pour tester try: factory = self.cial.FactoryDocumentVente persist = factory.List(1) if persist: doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() objets_a_tester.append(("Document", doc)) except: pass for nom_objet, objet in objets_a_tester: logger.info(f"\n{'='*60}") logger.info(f"OBJET: {nom_objet}") logger.info(f"{'='*60}") # Chercher tous les attributs qui contiennent "print", "etat", "bilan", "crystal", "report" mots_cles = ["print", "etat", "bilan", "crystal", "report", "pdf", "export", "impression", "imprimer"] attributs_trouves = [] for attr in dir(objet): if attr.startswith('_'): continue attr_lower = attr.lower() # Vérifier si contient un des mots-clés if any(mot in attr_lower for mot in mots_cles): try: val = getattr(objet, attr) type_val = type(val).__name__ is_callable = callable(val) attributs_trouves.append({ "nom": attr, "type": type_val, "callable": is_callable }) logger.info(f" ✅ TROUVÉ: {attr} ({type_val}) {'[MÉTHODE]' if is_callable else '[PROPRIÉTÉ]'}") except Exception as e: logger.debug(f" Erreur {attr}: {e}") if not attributs_trouves: logger.warning(f" ❌ Aucun objet d'impression trouvé sur {nom_objet}") # Tester des noms de méthodes spécifiques logger.info(f"\n{'='*60}") logger.info("TESTS DIRECTS") logger.info(f"{'='*60}") methodes_a_tester = [ ("self.cial.BilanEtat", lambda: self.cial.BilanEtat), ("self.cial.Etat", lambda: self.cial.Etat), ("self.cial.CptaApplication.BilanEtat", lambda: self.cial.CptaApplication.BilanEtat), ("self.cial.CptaApplication.Etat", lambda: self.cial.CptaApplication.Etat), ("self.cial.FactoryEtat", lambda: self.cial.FactoryEtat), ("self.cial.CptaApplication.FactoryEtat", lambda: self.cial.CptaApplication.FactoryEtat), ] for nom, getter in methodes_a_tester: try: obj = getter() logger.info(f" ✅ {nom} EXISTE : {type(obj).__name__}") except AttributeError as e: logger.info(f" ❌ {nom} N'EXISTE PAS : {e}") except Exception as e: logger.info(f" ⚠️ {nom} ERREUR : {e}") logger.info("=" * 80) return {"diagnostic": "terminé"} except Exception as e: logger.error(f"Erreur diagnostic: {e}", exc_info=True) raise def lister_objets_com_disponibles(self): """🔍 Liste tous les objets COM disponibles dans Sage""" try: with self._com_context(), self._lock_com: objets_trouves = { "cial": [], "cpta_application": [], "document": [] } # 1. Objets sur self.cial for attr in dir(self.cial): if not attr.startswith('_'): try: obj = getattr(self.cial, attr) objets_trouves["cial"].append({ "nom": attr, "type": str(type(obj)), "callable": callable(obj) }) except: pass # 2. Objets sur CptaApplication try: cpta = self.cial.CptaApplication for attr in dir(cpta): if not attr.startswith('_'): try: obj = getattr(cpta, attr) objets_trouves["cpta_application"].append({ "nom": attr, "type": str(type(obj)), "callable": callable(obj) }) except: pass except: pass # 3. Objets sur un document try: factory = self.cial.FactoryDocumentVente persist = factory.List(1) if persist: doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() for attr in dir(doc): if not attr.startswith('_'): try: obj = getattr(doc, attr) objets_trouves["document"].append({ "nom": attr, "type": str(type(obj)), "callable": callable(obj) }) except: pass except: pass return objets_trouves except Exception as e: logger.error(f"Erreur listage objets COM: {e}", exc_info=True) raise def explorer_methodes_impression(self): """Explore toutes les méthodes d'impression disponibles""" try: with self._com_context(), self._lock_com: # Charger un document de test factory = self.cial.FactoryDocumentVente persist = factory.List(1) if not persist: return {"error": "Aucun document trouvé"} doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() methods = {} # Tester différentes signatures de Print signatures_to_test = [ "Print", "PrintToFile", "Export", "ExportToPDF", "SaveAs", "GeneratePDF" ] for method_name in signatures_to_test: if hasattr(doc, method_name): try: # Essayer d'appeler pour voir les paramètres method = getattr(doc, method_name) methods[method_name] = { "exists": True, "callable": callable(method) } except: methods[method_name] = {"exists": True, "error": "Access error"} return methods except Exception as e: return {"error": str(e)} def generer_pdf_document_via_print(self, numero: str, type_doc: int) -> bytes: """Utilise la méthode Print() native des documents Sage""" try: with self._com_context(), self._lock_com: # Charger le document factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(type_doc, numero) if not persist: persist = self._find_document_in_list(numero, type_doc) if not persist: raise ValueError(f"Document {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() # Créer un fichier temporaire import tempfile import os temp_dir = tempfile.gettempdir() pdf_path = os.path.join(temp_dir, f"document_{numero}.pdf") # Utiliser Print() avec destination fichier PDF # Les codes de destination typiques dans Sage : # 0 = Imprimante par défaut # 1 = Aperçu # 2 = Fichier # 6 = PDF (dans certaines versions) try: # Tentative 1 : Print() avec paramètres doc.Print(Destination=6, FileName=pdf_path, Preview=False) except: # Tentative 2 : Print() simplifié try: doc.Print(pdf_path) # Certaines versions acceptent juste le chemin except: # Tentative 3 : PrintToFile() try: doc.PrintToFile(pdf_path) except AttributeError: raise RuntimeError("Aucune méthode d'impression disponible") # Lire le fichier PDF import time max_wait = 10 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("Le fichier PDF n'a pas été généré") with open(pdf_path, 'rb') as f: pdf_bytes = f.read() # Nettoyer try: os.remove(pdf_path) except: pass return pdf_bytes except Exception as e: logger.error(f"Erreur génération PDF via Print(): {e}") raise