import win32com.client import pythoncom from datetime import datetime, date from typing import Dict, List, Optional import threading import time import logging import pyodbc from contextlib import contextmanager import pywintypes from utils.articles.articles_data_sql import ( enrichir_stock_emplacements, enrichir_gammes_articles, enrichir_tarifs_clients, enrichir_nomenclature, enrichir_compta_articles, enrichir_fournisseurs_multiples, enrichir_depots_details, enrichir_emplacements_details, enrichir_gammes_enumeres, enrichir_references_enumerees, enrichir_medias_articles, enrichir_prix_gammes, enrichir_conditionnements, _mapper_article_depuis_row, enrichir_stocks_articles, enrichir_fournisseurs_articles, enrichir_familles_articles, enrichir_tva_articles, ) from utils.tiers.clients.clients_data import ( _extraire_client, _cast_client, ) from utils.articles.stock_check import verifier_stock_suffisant from utils.articles.articles_data_com import _extraire_article from utils.tiers.tiers_data_sql import _build_tiers_select_query from utils.functions.functions import ( _safe_strip, _safe_int, _clean_str, _try_set_attribute, _get_type_libelle, ) from utils.functions.items_to_dict import ( _contact_to_dict, _row_to_contact_dict, _row_to_tiers_dict, ) from utils.functions.sage_utilities import ( peut_etre_transforme, lire_erreurs_sage, ) from utils.documents.documents_data_sql import ( _rechercher_devis_par_numero, _lire_document_sql, _lister_documents_avec_lignes_sql, ) from utils.documents.devis.devis_check import _rechercher_devis_dans_liste from utils.tiers.contacts.contacts import ( _get_contacts, _chercher_contact_en_base, _lire_contact_depuis_base, ) from utils import ( valider_donnees_creation, valider_donnees_modification, ) from schemas.documents.doc_config import TypeDocumentVente from utils.functions.data.create_doc import ( creer_document_vente, modifier_document_vente, ) from utils.functions.items_to_dict import row_to_collaborateur_dict logger = logging.getLogger(__name__) class SageConnector: def __init__( self, chemin_base, sql_server_name, sql_server_database, 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 = sql_server_name self.sql_database = sql_server_database 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() self._thread_local = threading.local() @contextmanager def _com_context(self): 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: 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 _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 Exception: pass def connecter(self): """Connexion initiale à Sage - VERSION HYBRIDE""" try: 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}") try: with self._get_sql_connection() as conn: conn.cursor() 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 Exception: pass def lister_tous_fournisseurs(self, filtre=""): """Liste tous les fournisseurs avec leur commercial""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = _build_tiers_select_query() query += """ FROM F_COMPTET t LEFT JOIN F_COLLABORATEUR c ON t.CO_No = c.CO_No WHERE t.CT_Type = 1 """ params = [] if filtre: query += " AND (t.CT_Num LIKE ? OR t.CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY t.CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() fournisseurs = [] for row in rows: fournisseur = _row_to_tiers_dict(row) fournisseur["contacts"] = _get_contacts(row.CT_Num, conn) fournisseurs.append(fournisseur) logger.info( f"✓ SQL: {len(fournisseurs)} fournisseurs avec {len(fournisseur)} champs" ) return fournisseurs except Exception as e: logger.error(f"✗ Erreur SQL fournisseurs: {e}") raise RuntimeError(f"Erreur lecture fournisseurs: {str(e)}") def lire_fournisseur(self, code_fournisseur): """Lit un fournisseur avec son commercial""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = _build_tiers_select_query() query += """ FROM F_COMPTET t LEFT JOIN F_COLLABORATEUR c ON t.CO_No = c.CO_No WHERE t.CT_Num = ? AND t.CT_Type = 1 """ cursor.execute(query, (code_fournisseur.upper(),)) row = cursor.fetchone() if not row: return None fournisseur = _row_to_tiers_dict(row) fournisseur["contacts"] = _get_contacts(row.CT_Num, conn) logger.info( f"✓ SQL: Fournisseur {code_fournisseur} avec {len(fournisseur)} champs" ) return fournisseur except Exception as e: logger.error(f"✗ Erreur SQL fournisseur {code_fournisseur}: {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: logger.info(" === VALIDATION DONNÉES FOURNISSEUR ===") if not fournisseur_data.get("intitule"): raise ValueError("Le champ 'intitule' est obligatoire") 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", "4010000"))[ :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)})") factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist = factory_fournisseur.Create() fournisseur = win32com.client.CastTo(persist, "IBOFournisseur3") fournisseur.SetDefault() logger.info(" Objet fournisseur créé et initialisé") logger.info(" Définition des champs obligatoires...") fournisseur.CT_Intitule = intitule logger.debug(f" CT_Intitule: '{intitule}'") try: fournisseur.CT_Type = 1 logger.debug(" CT_Type: 1 (Fournisseur)") except Exception: logger.debug(" CT_Type non défini (géré par FactoryFournisseur)") try: fournisseur.CT_Qualite = "FOU" logger.debug(" CT_Qualite: 'FOU'") except Exception: logger.debug(" CT_Qualite non défini (pas critique)") 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() 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}") if num_prop: fournisseur.CT_Num = num_prop logger.debug(f" CT_Num fourni: '{num_prop}'") else: try: if hasattr(fournisseur, "SetDefaultNumPiece"): fournisseur.SetDefaultNumPiece() num_genere = getattr(fournisseur, "CT_Num", "") logger.debug(f" CT_Num auto-généré: '{num_genere}'") else: num_genere = factory_fournisseur.GetNextNumero() if num_genere: fournisseur.CT_Num = num_genere logger.debug( f" CT_Num auto (GetNextNumero): '{num_genere}'" ) else: 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" ) 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}") logger.info(" Définition champs optionnels...") 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}") 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}") 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}") 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}") 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}'") logger.info(" Écriture du fournisseur dans Sage...") 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})" ) logger.error(f" Erreur Sage: {error_detail}") except Exception: pass 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}") 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} ") resultat = { "numero": num_final, "intitule": intitule, "compte_collectif": compte, "type": 1, "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, } 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 Exception: 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: 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 = _cast_client(persist) if not fournisseur: raise ValueError(f"Impossible de charger le fournisseur {code}") logger.info( f" Fournisseur {code} trouvé: {getattr(fournisseur, 'CT_Intitule', '')}" ) logger.info(" Mise à jour des champs...") champs_modifies = [] if "intitule" in fournisseur_data: intitule = str(fournisseur_data["intitule"])[:69].strip() fournisseur.CT_Intitule = intitule champs_modifies.append(f"intitule='{intitule}'") 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}") 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}") 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}") 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") 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)}") 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 Exception: pass logger.error(f" Erreur Write(): {error_detail}") raise RuntimeError(f"Échec modification: {error_detail}") fournisseur.Read() logger.info( f" FOURNISSEUR MODIFIÉ: {code} ({len(champs_modifies)} champs) " ) numero = getattr(fournisseur, "CT_Num", "").strip() intitule = getattr(fournisseur, "CT_Intitule", "").strip() data = { "numero": numero, "intitule": intitule, "type": 1, "est_fournisseur": True, } 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 Exception: data["adresse"] = "" data["code_postal"] = "" data["ville"] = "" 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 Exception: 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 Exception: pass raise RuntimeError(f"Erreur technique Sage: {error_message}") def lister_tous_clients(self, filtre=""): """Liste tous les clients avec leur commercial""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = _build_tiers_select_query() query += """ FROM F_COMPTET t LEFT JOIN F_COLLABORATEUR c ON t.CO_No = c.CO_No WHERE t.CT_Type = 0 """ params = [] if filtre: query += " AND (t.CT_Num LIKE ? OR t.CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY t.CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() clients = [] for row in rows: client = _row_to_tiers_dict(row) client["contacts"] = _get_contacts(row.CT_Num, conn) clients.append(client) logger.info(f"✓ SQL: {len(clients)} clients avec {len(client)} champs") 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): """Lit un client avec son commercial""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = _build_tiers_select_query() query += """ FROM F_COMPTET t LEFT JOIN F_COLLABORATEUR c ON t.CO_No = c.CO_No WHERE t.CT_Num = ? AND t.CT_Type = 0 """ cursor.execute(query, (code_client.upper(),)) row = cursor.fetchone() if not row: return None client = _row_to_tiers_dict(row) client["contacts"] = _get_contacts(row.CT_Num, conn) logger.info(f"✓ SQL: Client {code_client} avec {len(client)} champs") return client except Exception as e: logger.error(f"✗ Erreur SQL client {code_client}: {e}") return None def lister_tous_articles(self, filtre=""): try: with self._get_sql_connection() as conn: cursor = conn.cursor() logger.info("[SQL] Détection des colonnes de F_ARTICLE...") cursor.execute("SELECT TOP 1 * FROM F_ARTICLE") colonnes_disponibles = [column[0] for column in cursor.description] logger.info( f"[SQL] Colonnes F_ARTICLE trouvées : {len(colonnes_disponibles)}" ) colonnes_config = { "AR_Ref": "reference", "AR_Design": "designation", "AR_CodeBarre": "code_barre", "AR_EdiCode": "edi_code", "AR_Raccourci": "raccourci", "AR_PrixVen": "prix_vente", "AR_PrixAch": "prix_achat", "AR_Coef": "coef", "AR_PUNet": "prix_net", "AR_PrixAchNouv": "prix_achat_nouveau", "AR_CoefNouv": "coef_nouveau", "AR_PrixVenNouv": "prix_vente_nouveau", "AR_DateApplication": "date_application_prix", "AR_CoutStd": "cout_standard", "AR_UniteVen": "unite_vente", "AR_UnitePoids": "unite_poids", "AR_PoidsNet": "poids_net", "AR_PoidsBrut": "poids_brut", "AR_Gamme1": "gamme_1", "AR_Gamme2": "gamme_2", "FA_CodeFamille": "famille_code", "AR_Type": "type_article", "AR_Nature": "nature", "AR_Garantie": "garantie", "AR_CodeFiscal": "code_fiscal", "AR_Pays": "pays", "CO_No": "fournisseur_principal", "AR_Condition": "conditionnement", "AR_NbColis": "nb_colis", "AR_Prevision": "prevision", "AR_SuiviStock": "suivi_stock", "AR_Nomencl": "nomenclature", "AR_QteComp": "qte_composant", "AR_QteOperatoire": "qte_operatoire", "AR_Sommeil": "sommeil", "AR_Substitut": "article_substitut", "AR_Escompte": "soumis_escompte", "AR_Delai": "delai", "AR_Stat01": "stat_01", "AR_Stat02": "stat_02", "AR_Stat03": "stat_03", "AR_Stat04": "stat_04", "AR_Stat05": "stat_05", "AR_HorsStat": "hors_statistique", "CL_No1": "categorie_1", "CL_No2": "categorie_2", "CL_No3": "categorie_3", "CL_No4": "categorie_4", "AR_DateModif": "date_modification", "AR_VteDebit": "vente_debit", "AR_NotImp": "non_imprimable", "AR_Transfere": "transfere", "AR_Publie": "publie", "AR_Contremarque": "contremarque", "AR_FactPoids": "fact_poids", "AR_FactForfait": "fact_forfait", "AR_SaisieVar": "saisie_variable", "AR_Fictif": "fictif", "AR_SousTraitance": "sous_traitance", "AR_Criticite": "criticite", "RP_CodeDefaut": "reprise_code_defaut", "AR_DelaiFabrication": "delai_fabrication", "AR_DelaiPeremption": "delai_peremption", "AR_DelaiSecurite": "delai_securite", "AR_TypeLancement": "type_lancement", "AR_Cycle": "cycle", "AR_Photo": "photo", "AR_Langue1": "langue_1", "AR_Langue2": "langue_2", "AR_Frais01FR_Denomination": "frais_01_denomination", "AR_Frais02FR_Denomination": "frais_02_denomination", "AR_Frais03FR_Denomination": "frais_03_denomination", "Marque commerciale": "marque_commerciale", "Objectif / Qtés vendues": "objectif_qtes_vendues", "Pourcentage teneur en or": "pourcentage_or", "1ère commercialisation": "premiere_commercialisation", "AR_InterdireCommande": "interdire_commande", "AR_Exclure": "exclure", } colonnes_a_lire = [ col_sql for col_sql in colonnes_config.keys() if col_sql in colonnes_disponibles ] if not colonnes_a_lire: logger.error("[SQL] Aucune colonne mappée trouvée !") colonnes_a_lire = ["AR_Ref", "AR_Design", "AR_PrixVen"] logger.info(f"[SQL] Colonnes sélectionnées : {len(colonnes_a_lire)}") colonnes_sql = [] for col in colonnes_a_lire: if " " in col or "/" in col or "è" in col: colonnes_sql.append(f"[{col}]") else: colonnes_sql.append(col) colonnes_str = ", ".join(colonnes_sql) query = f"SELECT {colonnes_str} FROM F_ARTICLE WHERE 1=1" params = [] if filtre: conditions = [] if "AR_Ref" in colonnes_a_lire: conditions.append("AR_Ref LIKE ?") params.append(f"%{filtre}%") if "AR_Design" in colonnes_a_lire: conditions.append("AR_Design LIKE ?") params.append(f"%{filtre}%") if "AR_CodeBarre" in colonnes_a_lire: conditions.append("AR_CodeBarre LIKE ?") params.append(f"%{filtre}%") if conditions: query += " AND (" + " OR ".join(conditions) + ")" query += " ORDER BY AR_Ref" logger.debug(f"[SQL] Requête : {query[:200]}...") cursor.execute(query, params) rows = cursor.fetchall() logger.info(f"[SQL] {len(rows)} lignes récupérées") articles = [] for row in rows: row_data = {} for idx, col_sql in enumerate(colonnes_a_lire): valeur = row[idx] if isinstance(valeur, str): valeur = valeur.strip() row_data[col_sql] = valeur if "Marque commerciale" in row_data: logger.debug( f"[DEBUG] Marque commerciale trouvée: {row_data['Marque commerciale']}" ) article_data = _mapper_article_depuis_row(row_data, colonnes_config) articles.append(article_data) articles = enrichir_stocks_articles(articles, cursor) articles = enrichir_familles_articles(articles, cursor) articles = enrichir_fournisseurs_articles(articles, cursor) articles = enrichir_tva_articles(articles, cursor) articles = enrichir_stock_emplacements(articles, cursor) articles = enrichir_gammes_articles(articles, cursor) articles = enrichir_tarifs_clients(articles, cursor) articles = enrichir_nomenclature(articles, cursor) articles = enrichir_compta_articles(articles, cursor) articles = enrichir_fournisseurs_multiples(articles, cursor) articles = enrichir_depots_details(articles, cursor) articles = enrichir_emplacements_details(articles, cursor) articles = enrichir_gammes_enumeres(articles, cursor) articles = enrichir_references_enumerees(articles, cursor) articles = enrichir_medias_articles(articles, cursor) articles = enrichir_prix_gammes(articles, cursor) articles = enrichir_conditionnements(articles, cursor) 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): """ Lit un article complet depuis SQL avec tous les enrichissements Version fusionnée complète """ try: with self._get_sql_connection() as conn: cursor = conn.cursor() # === DÉTECTION DES COLONNES === logger.info(f"[SQL] Lecture article {reference}...") cursor.execute("SELECT TOP 1 * FROM F_ARTICLE") colonnes_disponibles = [column[0] for column in cursor.description] # Configuration du mapping complet colonnes_config = { "AR_Ref": "reference", "AR_Design": "designation", "AR_CodeBarre": "code_barre", "AR_EdiCode": "edi_code", "AR_Raccourci": "raccourci", "AR_PrixVen": "prix_vente", "AR_PrixAch": "prix_achat", "AR_Coef": "coef", "AR_PUNet": "prix_net", "AR_PrixAchNouv": "prix_achat_nouveau", "AR_CoefNouv": "coef_nouveau", "AR_PrixVenNouv": "prix_vente_nouveau", "AR_DateApplication": "date_application_prix", "AR_CoutStd": "cout_standard", "AR_UniteVen": "unite_vente", "AR_UnitePoids": "unite_poids", "AR_PoidsNet": "poids_net", "AR_PoidsBrut": "poids_brut", "AR_Gamme1": "gamme_1", "AR_Gamme2": "gamme_2", "FA_CodeFamille": "famille_code", "AR_Type": "type_article", "AR_Nature": "nature", "AR_Garantie": "garantie", "AR_CodeFiscal": "code_fiscal", "AR_Pays": "pays", "CO_No": "fournisseur_principal", "AR_Condition": "conditionnement", "AR_NbColis": "nb_colis", "AR_Prevision": "prevision", "AR_SuiviStock": "suivi_stock", "AR_Nomencl": "nomenclature", "AR_QteComp": "qte_composant", "AR_QteOperatoire": "qte_operatoire", "AR_Sommeil": "sommeil", "AR_Substitut": "article_substitut", "AR_Escompte": "soumis_escompte", "AR_Delai": "delai", "AR_Stat01": "stat_01", "AR_Stat02": "stat_02", "AR_Stat03": "stat_03", "AR_Stat04": "stat_04", "AR_Stat05": "stat_05", "AR_HorsStat": "hors_statistique", "CL_No1": "categorie_1", "CL_No2": "categorie_2", "CL_No3": "categorie_3", "CL_No4": "categorie_4", "AR_DateModif": "date_modification", "AR_VteDebit": "vente_debit", "AR_NotImp": "non_imprimable", "AR_Transfere": "transfere", "AR_Publie": "publie", "AR_Contremarque": "contremarque", "AR_FactPoids": "fact_poids", "AR_FactForfait": "fact_forfait", "AR_SaisieVar": "saisie_variable", "AR_Fictif": "fictif", "AR_SousTraitance": "sous_traitance", "AR_Criticite": "criticite", "RP_CodeDefaut": "reprise_code_defaut", "AR_DelaiFabrication": "delai_fabrication", "AR_DelaiPeremption": "delai_peremption", "AR_DelaiSecurite": "delai_securite", "AR_TypeLancement": "type_lancement", "AR_Cycle": "cycle", "AR_Photo": "photo", "AR_Langue1": "langue_1", "AR_Langue2": "langue_2", "AR_Frais01FR_Denomination": "frais_01_denomination", "AR_Frais02FR_Denomination": "frais_02_denomination", "AR_Frais03FR_Denomination": "frais_03_denomination", "Marque commerciale": "marque_commerciale", "Objectif / Qtés vendues": "objectif_qtes_vendues", "Pourcentage teneur en or": "pourcentage_or", "1ère commercialisation": "premiere_commercialisation", "AR_InterdireCommande": "interdire_commande", "AR_Exclure": "exclure", } # Sélection des colonnes disponibles colonnes_a_lire = [ col_sql for col_sql in colonnes_config.keys() if col_sql in colonnes_disponibles ] if not colonnes_a_lire: logger.error("[SQL] Aucune colonne mappée trouvée !") return None # Construction de la requête SQL avec échappement des noms de colonnes colonnes_sql = [] for col in colonnes_a_lire: if " " in col or "/" in col or "è" in col: colonnes_sql.append(f"[{col}]") else: colonnes_sql.append(col) colonnes_str = ", ".join(colonnes_sql) query = f"SELECT {colonnes_str} FROM F_ARTICLE WHERE AR_Ref = ?" logger.debug(f"[SQL] Requête : {query[:200]}...") cursor.execute(query, (reference.upper(),)) row = cursor.fetchone() if not row: logger.info(f"[SQL] Article {reference} non trouvé") return None # Construction du dictionnaire row_data row_data = {} for idx, col_sql in enumerate(colonnes_a_lire): valeur = row[idx] if isinstance(valeur, str): valeur = valeur.strip() row_data[col_sql] = valeur # Mapping de l'article article = _mapper_article_depuis_row(row_data, colonnes_config) # Enrichissements articles = [ article ] # Liste d'un seul article pour les fonctions d'enrichissement articles = enrichir_stocks_articles(articles, cursor) articles = enrichir_familles_articles(articles, cursor) articles = enrichir_fournisseurs_articles(articles, cursor) articles = enrichir_tva_articles(articles, cursor) articles = enrichir_stock_emplacements(articles, cursor) articles = enrichir_gammes_articles(articles, cursor) articles = enrichir_tarifs_clients(articles, cursor) articles = enrichir_nomenclature(articles, cursor) articles = enrichir_compta_articles(articles, cursor) articles = enrichir_fournisseurs_multiples(articles, cursor) articles = enrichir_depots_details(articles, cursor) articles = enrichir_emplacements_details(articles, cursor) articles = enrichir_gammes_enumeres(articles, cursor) articles = enrichir_references_enumerees(articles, cursor) articles = enrichir_medias_articles(articles, cursor) articles = enrichir_prix_gammes(articles, cursor) articles = enrichir_conditionnements(articles, cursor) logger.info(f"✓ Article {reference} lu avec tous les enrichissements") return articles[0] except Exception as e: logger.error(f"✗ Erreur SQL article {reference}: {e}", exc_info=True) return None def obtenir_contact(self, numero: str, contact_numero: int) -> Optional[Dict]: """ Récupère un contact spécifique par son CT_No """ try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CT_Num, CT_No, N_Contact, CT_Civilite, CT_Nom, CT_Prenom, CT_Fonction, N_Service, CT_Telephone, CT_TelPortable, CT_Telecopie, CT_EMail, CT_Facebook, CT_LinkedIn, CT_Skype FROM F_CONTACTT WHERE CT_Num = ? AND CT_No = ? """ cursor.execute(query, [numero, contact_numero]) row = cursor.fetchone() if not row: return None return _row_to_contact_dict(row) except Exception as e: logger.error(f"Erreur obtention contact: {e}") raise RuntimeError(f"Erreur lecture contact: {str(e)}") def lister_tous_devis_cache(self, filtre=""): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lister_documents_avec_lignes_sql(cursor, type_doc=0, filtre=filtre) def lire_devis_cache(self, numero): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, numero, type_doc=0) def lister_toutes_commandes_cache(self, filtre=""): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lister_documents_avec_lignes_sql(cursor, type_doc=1, filtre=filtre) def lire_commande_cache(self, numero): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, numero, type_doc=1) def lister_toutes_factures_cache(self, filtre=""): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lister_documents_avec_lignes_sql(cursor, type_doc=6, filtre=filtre) def lire_facture_cache(self, numero): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, 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=""): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lister_documents_avec_lignes_sql(cursor, type_doc=3, filtre=filtre) def lire_livraison_cache(self, numero): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, numero, type_doc=3) def lister_tous_avoirs_cache(self, filtre=""): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lister_documents_avec_lignes_sql(cursor, type_doc=5, filtre=filtre) def lire_avoir_cache(self, numero): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, numero, type_doc=5) def creer_devis_enrichi(self, devis_data: dict) -> Dict: """Crée un devis""" return creer_document_vente(self, devis_data, TypeDocumentVente.DEVIS) def _relire_devis(self, numero_devis, devis_data): """Relit le devis créé et extrait les informations finales.""" factory_doc = self.cial.FactoryDocumentVente persist_reread = factory_doc.ReadPiece(0, numero_devis) if not persist_reread: logger.debug("ReadPiece échoué, recherche dans List()...") persist_reread = _rechercher_devis_dans_liste(numero_devis, factory_doc) 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) reference_final = getattr(doc_final, "DO_Ref", "") date_livraison_final = None try: date_livr = getattr(doc_final, "DO_DateLivr", None) if date_livr: date_livraison_final = date_livr.strftime("%Y-%m-%d") except Exception: pass else: total_calcule = sum( ligne.get("montant_ligne_ht", 0) for ligne in devis_data["lignes"] ) total_ht = total_calcule total_ttc = round(total_calcule * 1.20, 2) statut_final = 0 reference_final = devis_data.get("reference", "") date_livraison_final = devis_data.get("date_livraison") logger.info(f" Total HT: {total_ht}€") logger.info(f" Total TTC: {total_ttc}€") logger.info(f" Statut final: {statut_final}") if reference_final: logger.info(f" Référence: {reference_final}") if date_livraison_final: logger.info(f" Date livraison: {date_livraison_final}") 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(devis_data.get("date_devis", "")), "date_livraison": date_livraison_final, "reference": reference_final, "statut": statut_final, } def modifier_devis(self, numero: str, devis_data: dict) -> Dict: """Modifie un devis""" return modifier_document_vente( self, numero, devis_data, TypeDocumentVente.DEVIS ) def _charger_devis(self, numero: str): """Charge un devis depuis Sage.""" logger.info(f" Chargement devis {numero}...") factory = self.cial.FactoryDocumentVente logger.info(" Tentative ReadPiece(0, numero)...") persist = factory.ReadPiece(0, numero) if not persist: logger.warning(" ReadPiece a échoué, recherche dans la liste...") persist = _rechercher_devis_par_numero(numero, factory) if not persist: raise ValueError(f" Devis {numero} INTROUVABLE") doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() logger.info(f" Devis {numero} chargé") return doc def lire_devis(self, numero_devis): try: with self._get_sql_connection() as conn: cursor = conn.cursor() devis = _lire_document_sql(cursor, numero_devis, type_doc=0) if not devis: return None 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): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, numero, type_doc) def transformer_document( self, numero_source, type_source, type_cible, conserver_document_source=True, verifier_doublons=True, ): if not self.cial: raise RuntimeError("Connexion Sage non établie") type_source = int(type_source) type_cible = int(type_cible) logger.info( f"[TRANSFORM] Transformation: {numero_source} ({type_source}) → type {type_cible}" ) transformations_valides = { (0, 10): ("Vente", "CreateProcess_Commander"), (0, 60): ("Vente", "CreateProcess_Facturer"), (10, 30): ("Vente", "CreateProcess_Livrer"), (10, 60): ("Vente", "CreateProcess_Facturer"), (30, 60): ("Vente", "CreateProcess_Facturer"), } if (type_source, type_cible) not in transformations_valides: raise ValueError( f"Transformation non autorisée: " f"{_get_type_libelle(type_source)} → {_get_type_libelle(type_cible)}" ) module, methode = transformations_valides[(type_source, type_cible)] logger.info(f"[TRANSFORM] Méthode: Transformation.{module}.{methode}()") try: with ( self._com_context(), self._lock_com, self._get_sql_connection() as conn, ): cursor = conn.cursor() if verifier_doublons: logger.info("[TRANSFORM] Vérification des doublons...") verif = peut_etre_transforme( cursor, numero_source, type_source, type_cible ) if not verif["possible"]: docs = [ d["numero"] for d in verif.get("documents_existants", []) ] raise ValueError( f"{verif['raison']}. Document(s) existant(s): {', '.join(docs)}" ) logger.info("[TRANSFORM] Aucun doublon détecté") factory = self.cial.FactoryDocumentVente logger.info(f"[TRANSFORM] Lecture de {numero_source}...") if not factory.ExistPiece(type_source, numero_source): raise ValueError(f"Document {numero_source} introuvable") persist_source = factory.ReadPiece(type_source, numero_source) if not persist_source: raise ValueError(f"Impossible de lire {numero_source}") doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") doc_source.Read() statut_source = getattr(doc_source, "DO_Statut", 0) nb_lignes_source = 0 try: factory_lignes = getattr(doc_source, "FactoryDocumentLigne", None) if factory_lignes: lignes_list = factory_lignes.List nb_lignes_source = lignes_list.Count if lignes_list else 0 except Exception: pass logger.info( f"[TRANSFORM] Source: statut={statut_source}, {nb_lignes_source} ligne(s)" ) if nb_lignes_source == 0: raise ValueError(f"Document {numero_source} vide (0 lignes)") logger.info("[TRANSFORM] 🔧 Création du transformer...") transformation = getattr(self.cial, "Transformation", None) if not transformation: raise RuntimeError("API Transformation non disponible") module_obj = getattr(transformation, module, None) if not module_obj: raise RuntimeError(f"Module {module} non disponible") methode_func = getattr(module_obj, methode, None) if not methode_func: raise RuntimeError(f"Méthode {methode} non disponible") transformer = methode_func() if not transformer: raise RuntimeError("Échec création transformer") logger.info("[TRANSFORM] Transformer créé") logger.info("[TRANSFORM] Configuration...") if hasattr(transformer, "ConserveDocuments"): try: transformer.ConserveDocuments = conserver_document_source logger.info( f"[TRANSFORM] ConserveDocuments = {conserver_document_source}" ) except Exception as e: logger.warning( f"[TRANSFORM] ConserveDocuments non modifiable: {e}" ) logger.info("[TRANSFORM] Ajout du document...") try: transformer.AddDocument(doc_source) logger.info("[TRANSFORM] Document ajouté") except Exception as e: raise RuntimeError(f"Impossible d'ajouter le document: {e}") try: can_process = getattr(transformer, "CanProcess", False) logger.info(f"[TRANSFORM] CanProcess: {can_process}") except Exception: can_process = True if not can_process: erreurs = lire_erreurs_sage(transformer, "Transformer") if erreurs: msgs = [f"{e['field']}: {e['description']}" for e in erreurs] raise RuntimeError( f"Transformation impossible: {' | '.join(msgs)}" ) raise RuntimeError("Transformation impossible (CanProcess=False)") transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("[TRANSFORM] Transaction démarrée") except Exception: pass try: logger.info("[TRANSFORM] Process()...") try: transformer.Process() logger.info("[TRANSFORM] Process() réussi") except Exception as e: logger.error(f"[TRANSFORM] Erreur Process(): {e}") erreurs = lire_erreurs_sage(transformer, "Transformer") if erreurs: msgs = [ f"{e['field']}: {e['description']}" for e in erreurs ] raise RuntimeError(f"Échec: {' | '.join(msgs)}") raise RuntimeError(f"Échec transformation: {e}") logger.info("[TRANSFORM] Récupération des résultats...") list_results = getattr(transformer, "ListDocumentsResult", None) if not list_results: raise RuntimeError("ListDocumentsResult non disponible") documents_crees = [] index = 1 while index <= 100: try: doc_result = list_results.Item(index) if doc_result is None: break doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() numero_cible = getattr(doc_result, "DO_Piece", "").strip() total_ht = float(getattr(doc_result, "DO_TotalHT", 0.0)) total_ttc = float(getattr(doc_result, "DO_TotalTTC", 0.0)) nb_lignes = 0 try: factory_lignes_result = getattr( doc_result, "FactoryDocumentLigne", None ) if factory_lignes_result: lignes_list = factory_lignes_result.List nb_lignes = lignes_list.Count if lignes_list else 0 except Exception: pass documents_crees.append( { "numero": numero_cible, "total_ht": total_ht, "total_ttc": total_ttc, "nb_lignes": nb_lignes, } ) logger.info( f"[TRANSFORM] Document créé: {numero_cible} " f"({nb_lignes} lignes, {total_ht}€ HT)" ) index += 1 except Exception: logger.debug(f"Fin de liste à index {index}") break if not documents_crees: raise RuntimeError("Aucun document créé après Process()") if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.debug("[TRANSFORM] Transaction committée") except Exception: pass time.sleep(1.5) doc_principal = documents_crees[0] logger.info( f"[TRANSFORM] SUCCÈS: {numero_source} → {doc_principal['numero']}" ) logger.info( f"[TRANSFORM] • {doc_principal['nb_lignes']} ligne(s)" ) logger.info( f"[TRANSFORM] • {doc_principal['total_ht']}€ HT / " f"{doc_principal['total_ttc']}€ TTC" ) return { "success": True, "document_source": numero_source, "document_cible": doc_principal["numero"], "type_source": type_source, "type_cible": type_cible, "nb_documents_crees": len(documents_crees), "documents": documents_crees, "nb_lignes": doc_principal["nb_lignes"], "total_ht": doc_principal["total_ht"], "total_ttc": doc_principal["total_ttc"], "methode_transformation": f"{module}.{methode}", } except Exception: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() logger.error("[TRANSFORM] Transaction annulée (rollback)") except Exception: pass raise except ValueError as e: logger.error(f"[TRANSFORM] Erreur métier: {e}") raise except RuntimeError as e: logger.error(f"[TRANSFORM] Erreur technique: {e}") raise except Exception as e: logger.error(f"[TRANSFORM] Erreur inattendue: {e}", exc_info=True) raise RuntimeError(f"Échec transformation: {str(e)}") 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 _cast_client(persist) except Exception: 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 = _cast_client(persist_client) if not client: return None contact_info = { "client_code": code_client, "client_intitule": getattr(client, "CT_Intitule", ""), "email": None, "nom": None, "telephone": None, } try: telecom = getattr(client, "Telecom", None) if telecom: contact_info["email"] = getattr(telecom, "EMail", "") contact_info["telephone"] = getattr(telecom, "Telephone", "") except Exception: pass try: contact_info["nom"] = ( getattr(client, "CT_Contact", "") or contact_info["client_intitule"] ) except Exception: 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": _safe_strip(row.CT_Num), "intitule": _safe_strip(row.CT_Intitule), "adresse": _safe_strip(row.CT_Adresse), "ville": _safe_strip(row.CT_Ville), "code_postal": _safe_strip(row.CT_CodePostal), "telephone": _safe_strip(row.CT_Telephone), "email": _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": _safe_strip(row.CT_Num), "intitule": _safe_strip(row.CT_Intitule), "type": 0, "qualite": _safe_strip(row.CT_Qualite), "est_prospect": True, "adresse": _safe_strip(row.CT_Adresse), "complement": _safe_strip(row.CT_Complement), "ville": _safe_strip(row.CT_Ville), "code_postal": _safe_strip(row.CT_CodePostal), "pays": _safe_strip(row.CT_Pays), "telephone": _safe_strip(row.CT_Telephone), "portable": _safe_strip(row.CT_Portable), "email": _safe_strip(row.CT_EMail), "telecopie": _safe_strip(row.CT_Telecopie), "siret": _safe_strip(row.CT_Siret), "tva_intra": _safe_strip(row.CT_Identifiant), "est_actif": (row.CT_Sommeil == 0), "contact": _safe_strip(row.CT_Contact), "forme_juridique": _safe_strip(row.CT_FormeJuridique), "secteur": _safe_strip(row.CT_Secteur), } except Exception as e: logger.error(f" Erreur SQL prospect {code_prospect}: {e}") return None def lire_avoir(self, numero): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, numero, type_doc=50) def lire_livraison(self, numero): with self._get_sql_connection() as conn: cursor = conn.cursor() return _lire_document_sql(cursor, numero, type_doc=30) def creer_contact(self, contact_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with ( self._com_context(), self._lock_com, self._get_sql_connection() as conn, ): logger.info("=" * 80) logger.info("[CREATION CONTACT F_CONTACTT]") logger.info("=" * 80) if not contact_data.get("numero"): raise ValueError("numero (code client) obligatoire") if not contact_data.get("nom"): raise ValueError("nom obligatoire") numero_client = _clean_str(contact_data["numero"], 17).upper() nom = _clean_str(contact_data["nom"], 35) prenom = _clean_str(contact_data.get("prenom", ""), 35) logger.info(f" CLIENT: {numero_client}") logger.info(f" CONTACT: {prenom} {nom}") logger.info(f"[1] Chargement du tiers: {numero_client}") factory_client = self.cial.CptaApplication.FactoryClient factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist_tiers = None type_tiers = None # Tentative 1 : Client try: logger.info(" Recherche dans Clients...") persist_tiers = factory_client.ReadNumero(numero_client) if persist_tiers: type_tiers = "Client" logger.info(" ✓ Trouvé comme Client") except Exception as e: logger.debug(f" Pas trouvé comme Client: {e}") # Tentative 2 : Fournisseur (si pas trouvé comme client) if not persist_tiers: try: logger.info(" Recherche dans Fournisseurs...") persist_tiers = factory_fournisseur.ReadNumero(numero_client) if persist_tiers: type_tiers = "Fournisseur" logger.info(" ✓ Trouvé comme Fournisseur") except Exception as e: logger.debug(f" Pas trouvé comme Fournisseur: {e}") # Vérification finale if not persist_tiers: raise ValueError( f"Le tiers '{numero_client}' est introuvable dans Sage 100c. " f"Vérifiez que le code est exact et que le tiers existe " f"(Client ou Fournisseur)." ) # Cast et lecture try: client_obj = win32com.client.CastTo(persist_tiers, "IBOClient3") client_obj.Read() logger.info(f" OK {type_tiers} chargé") except Exception as e: raise ValueError( f"Erreur lors du chargement du {type_tiers} '{numero_client}': {e}" ) logger.info("[2] Creation via FactoryTiersContact") if not hasattr(client_obj, "FactoryTiersContact"): raise RuntimeError("FactoryTiersContact non trouvee sur le client") factory_contact = client_obj.FactoryTiersContact logger.info( f" OK FactoryTiersContact: {type(factory_contact).__name__}" ) persist = factory_contact.Create() logger.info(f" Objet cree: {type(persist).__name__}") contact = None interfaces_a_tester = [ "IBOTiersContact3", "IBOTiersContact", "IBOContactT3", "IBOContactT", ] for interface_name in interfaces_a_tester: try: temp = win32com.client.CastTo(persist, interface_name) if hasattr(temp, "_prop_map_put_"): props = list(temp._prop_map_put_.keys()) logger.info(f" Test {interface_name}: props={props[:15]}") if "Nom" in props or "CT_Nom" in props: contact = temp logger.info(f" OK Cast reussi vers {interface_name}") break except Exception as e: logger.debug(f" {interface_name}: {str(e)[:50]}") if not contact: logger.error(" ERROR Aucun cast ne fonctionne") raise RuntimeError( "Impossible de caster vers une interface contact valide" ) logger.info("[3] Configuration du contact") if hasattr(contact, "_prop_map_put_"): props = list(contact._prop_map_put_.keys()) logger.info(f" Proprietes disponibles: {props}") try: contact.Nom = nom logger.info(f" OK Nom = {nom}") except Exception as e: logger.error(f" ERROR Impossible de definir Nom: {e}") raise RuntimeError(f"Echec definition Nom: {e}") if prenom: try: contact.Prenom = prenom logger.info(f" OK Prenom = {prenom}") except Exception as e: logger.warning(f" WARN Prenom: {e}") if contact_data.get("civilite"): civilite_map = {"M.": 0, "Mme": 1, "Mlle": 2, "Societe": 3} civilite_code = civilite_map.get(contact_data["civilite"]) if civilite_code is not None: try: contact.Civilite = civilite_code logger.info(f" OK Civilite = {civilite_code}") except Exception as e: logger.warning(f" WARN Civilite: {e}") if contact_data.get("fonction"): fonction = _clean_str(contact_data["fonction"], 35) try: contact.Fonction = fonction logger.info(f" OK Fonction = {fonction}") except Exception as e: logger.warning(f" WARN Fonction: {e}") if contact_data.get("service_code") is not None: try: service = _safe_int(contact_data["service_code"]) if service is not None and hasattr(contact, "ServiceContact"): contact.ServiceContact = service logger.info(f" OK ServiceContact = {service}") except Exception as e: logger.warning(f" WARN ServiceContact: {e}") logger.info("[4] Coordonnees (Telecom)") if hasattr(contact, "Telecom"): try: telecom = contact.Telecom logger.info(f" Type Telecom: {type(telecom).__name__}") if contact_data.get("telephone"): telephone = _clean_str(contact_data["telephone"], 21) if _try_set_attribute(telecom, "Telephone", telephone): logger.info(f" Telephone = {telephone}") if contact_data.get("portable"): portable = _clean_str(contact_data["portable"], 21) if _try_set_attribute(telecom, "Portable", portable): logger.info(f" Portable = {portable}") if contact_data.get("email"): email = _clean_str(contact_data["email"], 69) if _try_set_attribute(telecom, "EMail", email): logger.info(f" EMail = {email}") if contact_data.get("telecopie"): fax = _clean_str(contact_data["telecopie"], 21) if _try_set_attribute(telecom, "Telecopie", fax): logger.info(f" Telecopie = {fax}") except Exception as e: logger.warning(f" WARN Erreur Telecom: {e}") logger.info("[5] Reseaux sociaux") if contact_data.get("facebook"): facebook = _clean_str(contact_data["facebook"], 69) try: contact.Facebook = facebook logger.info(f" Facebook = {facebook}") except Exception: pass if contact_data.get("linkedin"): linkedin = _clean_str(contact_data["linkedin"], 69) try: contact.LinkedIn = linkedin logger.info(f" LinkedIn = {linkedin}") except Exception: pass if contact_data.get("skype"): skype = _clean_str(contact_data["skype"], 69) try: contact.Skype = skype logger.info(f" Skype = {skype}") except Exception: pass try: contact.SetDefault() logger.info(" OK SetDefault() applique") except Exception as e: logger.warning(f" WARN SetDefault(): {e}") logger.info("[6] Enregistrement du contact") contact_cree_malgre_erreur = False contact_no = None n_contact = None erreur_com = None try: contact.Write() logger.info(" Write() reussi") try: contact.Read() logger.info(" Read() reussi") except Exception as read_err: logger.warning(f" WARN Read() échoué: {read_err}") try: contact_no = getattr(contact, "CT_No", None) n_contact = getattr(contact, "N_Contact", None) logger.info( f" IDs COM: CT_No={contact_no}, N_Contact={n_contact}" ) except Exception: pass if not contact_no: logger.info( " 🔍 CT_No non disponible via COM - Recherche en base..." ) import time time.sleep(0.3) contact_sql = _chercher_contact_en_base( conn, numero_client=numero_client, nom=nom, prenom=prenom if prenom else None, ) if contact_sql: logger.info( f" Contact trouvé en base: CT_No={contact_sql['contact_numero']}" ) contact_no = contact_sql["contact_numero"] n_contact = contact_sql["n_contact"] else: logger.warning(" Contact non trouvé en base immédiatement") except Exception as e: erreur_com = str(e) logger.warning(f" Write() a levé une exception: {erreur_com}") if ( "existe déjà" in erreur_com.lower() or "already exists" in erreur_com.lower() ): logger.info( " 🔍 Erreur 'existe déjà' détectée - Vérification en base..." ) import time time.sleep(0.5) contact_sql = _chercher_contact_en_base( conn, numero_client=numero_client, nom=nom, prenom=prenom if prenom else None, ) if contact_sql: logger.info(" Contact CRÉÉ malgré l'erreur COM !") logger.info( f" CT_No={contact_sql['contact_numero']}, N_Contact={contact_sql['n_contact']}" ) contact_cree_malgre_erreur = True contact_no = contact_sql["contact_numero"] n_contact = contact_sql["n_contact"] else: logger.error(" Contact NON trouvé en base - Erreur réelle") raise RuntimeError(f"Echec enregistrement: {erreur_com}") else: logger.error(f" Erreur Write non gérée: {erreur_com}") raise RuntimeError(f"Echec enregistrement: {erreur_com}") est_defaut = contact_data.get("est_defaut", False) if est_defaut and (contact_no or n_contact): logger.info("[7] Definition comme contact par defaut") try: nom_complet = f"{prenom} {nom}".strip() if prenom else nom persist_client = factory_client.ReadNumero(numero_client) client_obj = win32com.client.CastTo( persist_client, "IBOClient3" ) client_obj.Read() client_obj.CT_Contact = nom_complet logger.info(f" CT_Contact = '{nom_complet}'") if contact_no and hasattr(client_obj, "CT_NoContact"): try: client_obj.CT_NoContact = contact_no logger.info(f" CT_NoContact = {contact_no}") except Exception: pass client_obj.Write() client_obj.Read() logger.info(" OK Contact par defaut defini") except Exception as e: logger.warning(f" WARN Echec: {e}") est_defaut = False logger.info("=" * 80) if contact_cree_malgre_erreur: logger.info( f"[SUCCES] Contact créé MALGRÉ erreur COM: {prenom} {nom}" ) logger.info( " (Bug connu de Sage 100c - Le contact est bien en base)" ) else: logger.info(f"[SUCCES] Contact créé: {prenom} {nom}") logger.info(f" Lié au client {numero_client}") if contact_no: logger.info(f" CT_No={contact_no}") logger.info("=" * 80) logger.info("[7] Construction du retour") contact_dict = None if contact_no: logger.info(f" Stratégie 1: Lecture base (CT_No={contact_no})") try: contact_dict = _lire_contact_depuis_base( conn, numero_client=numero_client, contact_no=contact_no ) if contact_dict: logger.info( f" Lecture base réussie: {len(contact_dict)} champs" ) logger.info(f" Type: {type(contact_dict)}") logger.info(f" Keys: {list(contact_dict.keys())}") logger.info( f" Sample: numero={contact_dict.get('numero')}, nom={contact_dict.get('nom')}" ) else: logger.warning( " _lire_contact_depuis_base() retourne None" ) except Exception as e: logger.error(f" Erreur lecture base: {e}", exc_info=True) contact_dict = None if not contact_dict: logger.info(" Stratégie 2: Lecture objet COM (_contact_to_dict)") try: contact_dict = _contact_to_dict( contact, numero_client=numero_client, contact_numero=contact_no, n_contact=n_contact, ) if contact_dict: logger.info( f" _contact_to_dict réussi: {len(contact_dict)} champs" ) else: logger.warning(" _contact_to_dict() retourne None/vide") except Exception as e: logger.error(f" Erreur _contact_to_dict: {e}", exc_info=True) contact_dict = None if not contact_dict: logger.info(" Stratégie 3: Construction manuelle (fallback)") contact_dict = self._construire_contact_minimal( numero_client=numero_client, contact_no=contact_no, n_contact=n_contact, nom=nom, prenom=prenom, contact_data=contact_data, ) logger.info( f" Contact minimal construit: {len(contact_dict)} champs" ) if not contact_dict or not isinstance(contact_dict, dict): logger.error( f" ERREUR: contact_dict invalide: type={type(contact_dict)}, value={contact_dict}" ) raise RuntimeError( "Impossible de construire le dictionnaire de retour" ) contact_dict["est_defaut"] = est_defaut logger.info(" DICT FINAL AVANT RETURN:") logger.info(f" Type: {type(contact_dict)}") logger.info(f" Len: {len(contact_dict)}") logger.info(f" Keys: {list(contact_dict.keys())}") for key, value in contact_dict.items(): logger.info(f" {key}: {value} (type: {type(value).__name__})") logger.info(" RETURN contact_dict") return contact_dict except ValueError as e: logger.error(f"[ERREUR VALIDATION] {e}") raise except Exception as e: logger.error(f"[ERREUR] {e}", exc_info=True) raise RuntimeError(f"Erreur technique: {e}") def _construire_contact_minimal( self, numero_client: str, contact_no: Optional[int], n_contact: Optional[int], nom: str, prenom: Optional[str], contact_data: Dict, ) -> Dict: logger.info( f" _construire_contact_minimal(client={numero_client}, CT_No={contact_no})" ) civilite_map_reverse = {0: "M.", 1: "Mme", 2: "Mlle", 3: "Société"} civilite_input = contact_data.get("civilite") civilite_code = None if civilite_input: if isinstance(civilite_input, int): civilite_code = civilite_map_reverse.get(civilite_input) else: civilite_code = civilite_input result = { "numero": numero_client, "contact_numero": contact_no, "n_contact": n_contact, "civilite": civilite_code, "nom": nom, "prenom": prenom, "fonction": contact_data.get("fonction"), "service_code": contact_data.get("service_code"), "telephone": contact_data.get("telephone"), "portable": contact_data.get("portable"), "telecopie": contact_data.get("telecopie"), "email": contact_data.get("email"), "facebook": contact_data.get("facebook"), "linkedin": contact_data.get("linkedin"), "skype": contact_data.get("skype"), "est_defaut": False, } logger.info( f" Contact minimal: numero={result['numero']}, nom={result['nom']}, email={result['email']}" ) return result def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info("=" * 80) logger.info(f"[MODIFICATION CONTACT] CT_No={contact_numero}") logger.info("=" * 80) logger.info("[1] Chargement du tiers") factory_client = self.cial.CptaApplication.FactoryClient factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur persist_tiers = None type_tiers = None # Tentative 1 : Client try: logger.info(" Recherche dans Clients...") persist_tiers = factory_client.ReadNumero(numero) if persist_tiers: type_tiers = "Client" logger.info(" ✓ Trouvé comme Client") except Exception as e: logger.debug(f" Pas trouvé comme Client: {e}") # Tentative 2 : Fournisseur (si pas trouvé comme client) if not persist_tiers: try: logger.info(" Recherche dans Fournisseurs...") persist_tiers = factory_fournisseur.ReadNumero(numero) if persist_tiers: type_tiers = "Fournisseur" logger.info(" ✓ Trouvé comme Fournisseur") except Exception as e: logger.debug(f" Pas trouvé comme Fournisseur: {e}") # Vérification finale if not persist_tiers: raise ValueError( f"Le tiers '{numero}' est introuvable dans Sage 100c. " f"Vérifiez que le code est exact et que le tiers existe " f"(Client ou Fournisseur)." ) # Cast et lecture try: client_obj = win32com.client.CastTo(persist_tiers, "IBOClient3") client_obj.Read() logger.info(f" OK {type_tiers} chargé: {client_obj.CT_Intitule}") except Exception as e: raise ValueError( f"Erreur lors du chargement du {type_tiers} '{numero}': {e}" ) logger.info("[2] Chargement du contact") contact = None nom_recherche = None prenom_recherche = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( """SELECT CT_No, CT_Nom, CT_Prenom, cbMarq FROM F_CONTACTT WHERE CT_Num = ? AND CT_No = ?""", [numero, contact_numero], ) row = cursor.fetchone() if not row: raise ValueError( f"Contact CT_No={contact_numero} non trouve en base" ) ct_no_sql = row.CT_No nom_recherche = row.CT_Nom.strip() if row.CT_Nom else "" prenom_recherche = ( row.CT_Prenom.strip() if row.CT_Prenom else "" ) cbmarq_sql = row.cbMarq logger.info( f" Contact SQL: CT_No={ct_no_sql}, cbMarq={cbmarq_sql}" ) logger.info( f" Nom='{nom_recherche}', Prenom='{prenom_recherche}'" ) except ValueError: raise except Exception as e: raise ValueError(f"Erreur lecture contact en base: {e}") logger.info(" Strategie 1: Parcours FactoryTiersContact du client...") try: factory_contact = client_obj.FactoryTiersContact if hasattr(factory_contact, "List"): liste_contacts = factory_contact.List if liste_contacts and hasattr(liste_contacts, "Count"): count = liste_contacts.Count logger.info(f" {count} contact(s) pour ce client") for i in range(count): try: item = liste_contacts.Item(i + 1) temp_contact = None for iface in [ "IBOTiersContact3", "IBOTiersContact", "IBOContactT3", ]: try: temp_contact = win32com.client.CastTo( item, iface ) break except Exception: continue if not temp_contact: continue temp_contact.Read() nom_com = getattr(temp_contact, "Nom", "") or "" prenom_com = ( getattr(temp_contact, "Prenom", "") or "" ) if ( nom_com.strip().lower() == nom_recherche.lower() and prenom_com.strip().lower() == prenom_recherche.lower() ): contact = temp_contact logger.info( f" OK Contact trouve a l'index {i + 1}: '{prenom_com}' '{nom_com}'" ) break except Exception as e: logger.debug(f" Item {i + 1} erreur: {e}") continue except Exception as e: logger.warning(f" Strategie 1 echouee: {e}") if not contact: logger.info(" Strategie 2: FactoryDossierContact.ReadNomPrenom...") try: factory_dossier = ( self.cial.CptaApplication.FactoryDossierContact ) persist = factory_dossier.ReadNomPrenom( nom_recherche, prenom_recherche ) if persist: contact = win32com.client.CastTo( persist, "IBOTiersContact3" ) contact.Read() logger.info(" OK Contact charge via ReadNomPrenom") except Exception as e: logger.warning(f" Strategie 2 echouee: {e}") if not contact: logger.info(" Strategie 3: Variations nom/prenom...") try: factory_dossier = ( self.cial.CptaApplication.FactoryDossierContact ) variations = [ (nom_recherche.upper(), prenom_recherche.upper()), (nom_recherche.lower(), prenom_recherche.lower()), (nom_recherche.capitalize(), prenom_recherche.capitalize()), (nom_recherche, ""), ] for nom_var, prenom_var in variations: try: persist = factory_dossier.ReadNomPrenom( nom_var, prenom_var ) if persist: contact = win32com.client.CastTo( persist, "IBOTiersContact3" ) contact.Read() logger.info( f" OK Contact trouve avec: '{nom_var}'/'{prenom_var}'" ) break except Exception: continue except Exception as e: logger.warning(f" Strategie 3 echouee: {e}") if not contact: logger.info( " Strategie 4: Parcours global FactoryDossierContact..." ) try: factory_dossier = ( self.cial.CptaApplication.FactoryDossierContact ) if hasattr(factory_dossier, "List"): liste = factory_dossier.List if liste and hasattr(liste, "Count"): count = min(liste.Count, 500) logger.info(f" Parcours de {count} contacts...") for i in range(count): try: item = liste.Item(i + 1) temp = win32com.client.CastTo( item, "IBOTiersContact3" ) temp.Read() nom = getattr(temp, "Nom", "") or "" prenom = getattr(temp, "Prenom", "") or "" if ( nom.strip().lower() == nom_recherche.lower() and prenom.strip().lower() == prenom_recherche.lower() ): contact = temp logger.info( f" OK Contact trouve a l'index global {i + 1}" ) break except Exception: continue except Exception as e: logger.warning(f" Strategie 4 echouee: {e}") if not contact: logger.error( f" ECHEC: Impossible de charger le contact CT_No={contact_numero}" ) raise ValueError( f"Contact CT_No={contact_numero} introuvable via COM. " f"Nom='{nom_recherche}', Prenom='{prenom_recherche}'" ) logger.info(f" OK Contact charge: {contact.Nom}") logger.info("[3] Application des modifications") modifications_appliquees = [] if "civilite" in updates: civilite_map = {"M.": 0, "Mme": 1, "Mlle": 2, "Societe": 3} civilite_code = civilite_map.get(updates["civilite"]) if civilite_code is not None: try: contact.Civilite = civilite_code logger.info(f" Civilite = {civilite_code}") modifications_appliquees.append("civilite") except Exception as e: logger.warning(f" WARN Civilite: {e}") if "nom" in updates: nom = _clean_str(updates["nom"], 35) if nom: try: contact.Nom = nom logger.info(f" Nom = {nom}") modifications_appliquees.append("nom") except Exception as e: logger.warning(f" WARN Nom: {e}") if "prenom" in updates: prenom = _clean_str(updates["prenom"], 35) try: contact.Prenom = prenom logger.info(f" Prenom = {prenom}") modifications_appliquees.append("prenom") except Exception as e: logger.warning(f" WARN Prenom: {e}") if "fonction" in updates: fonction = _clean_str(updates["fonction"], 35) try: contact.Fonction = fonction logger.info(f" Fonction = {fonction}") modifications_appliquees.append("fonction") except Exception as e: logger.warning(f" WARN Fonction: {e}") if "service_code" in updates: service = _safe_int(updates["service_code"]) if service is not None and hasattr(contact, "ServiceContact"): try: contact.ServiceContact = service logger.info(f" ServiceContact = {service}") modifications_appliquees.append("service_code") except Exception as e: logger.warning(f" WARN ServiceContact: {e}") if hasattr(contact, "Telecom"): try: telecom = contact.Telecom if "telephone" in updates: telephone = _clean_str(updates["telephone"], 21) if _try_set_attribute(telecom, "Telephone", telephone): logger.info(f" Telephone = {telephone}") modifications_appliquees.append("telephone") if "portable" in updates: portable = _clean_str(updates["portable"], 21) if _try_set_attribute(telecom, "Portable", portable): logger.info(f" Portable = {portable}") modifications_appliquees.append("portable") if "email" in updates: email = _clean_str(updates["email"], 69) if _try_set_attribute(telecom, "EMail", email): logger.info(f" EMail = {email}") modifications_appliquees.append("email") if "telecopie" in updates: fax = _clean_str(updates["telecopie"], 21) if _try_set_attribute(telecom, "Telecopie", fax): logger.info(f" Telecopie = {fax}") modifications_appliquees.append("telecopie") except Exception as e: logger.warning(f" WARN Telecom: {e}") if "facebook" in updates: facebook = _clean_str(updates["facebook"], 69) try: contact.Facebook = facebook logger.info(f" Facebook = {facebook}") modifications_appliquees.append("facebook") except Exception as e: logger.warning(f" WARN Facebook: {e}") if "linkedin" in updates: linkedin = _clean_str(updates["linkedin"], 69) try: contact.LinkedIn = linkedin logger.info(f" LinkedIn = {linkedin}") modifications_appliquees.append("linkedin") except Exception as e: logger.warning(f" WARN LinkedIn: {e}") if "skype" in updates: skype = _clean_str(updates["skype"], 69) try: contact.Skype = skype logger.info(f" Skype = {skype}") modifications_appliquees.append("skype") except Exception as e: logger.warning(f" WARN Skype: {e}") logger.info( f" Modifications preparees: {', '.join(modifications_appliquees) if modifications_appliquees else 'aucune'}" ) logger.info("[4] Enregistrement") try: contact.Write() logger.info(" Write() reussi") 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 Exception: pass logger.error(f" ERROR Write: {error_detail}") raise RuntimeError(f"Echec modification contact: {error_detail}") try: contact.Read() logger.info(" Read() reussi") except Exception as e: logger.warning(f" WARN Read() apres Write: {e}") est_defaut_demande = updates.get("est_defaut") est_actuellement_defaut = False if est_defaut_demande: logger.info("[5] Definition comme contact par defaut") try: nom_complet = ( f"{contact.Prenom} {contact.Nom}".strip() if contact.Prenom else contact.Nom ) persist_client = factory_client.ReadNumero(numero) client_obj = win32com.client.CastTo( persist_client, "IBOClient3" ) client_obj.Read() client_obj.CT_Contact = nom_complet logger.info(f" CT_Contact = '{nom_complet}'") if hasattr(client_obj, "CT_NoContact"): try: client_obj.CT_NoContact = contact_numero logger.info(f" CT_NoContact = {contact_numero}") except Exception as e: logger.warning(f" WARN CT_NoContact: {e}") client_obj.Write() client_obj.Read() logger.info(" OK Contact par defaut defini") est_actuellement_defaut = True except Exception as e: logger.warning(f" WARN Echec definition contact defaut: {e}") logger.info("[6] Construction du retour") contact_dict = None try: with self._get_sql_connection() as conn: contact_dict = _lire_contact_depuis_base( conn, numero_client=numero, contact_no=contact_numero ) if contact_dict: logger.info(" Lecture base reussie") except Exception as e: logger.warning(f" Lecture base echouee: {e}") if not contact_dict: try: contact_dict = _contact_to_dict( contact, numero_client=numero, contact_numero=contact_numero, n_contact=None, ) if contact_dict: logger.info(" _contact_to_dict reussi") except Exception as e: logger.warning(f" _contact_to_dict echoue: {e}") if not contact_dict: logger.info(" Construction manuelle du retour") contact_dict = { "numero": numero, "contact_numero": contact_numero, "n_contact": None, "civilite": None, "nom": getattr(contact, "Nom", nom_recherche), "prenom": getattr(contact, "Prenom", prenom_recherche), "fonction": getattr(contact, "Fonction", None), "service_code": None, "telephone": None, "portable": None, "telecopie": None, "email": None, "facebook": None, "linkedin": None, "skype": None, } if hasattr(contact, "Telecom"): try: telecom = contact.Telecom contact_dict["telephone"] = getattr( telecom, "Telephone", None ) contact_dict["portable"] = getattr( telecom, "Portable", None ) contact_dict["email"] = getattr(telecom, "EMail", None) contact_dict["telecopie"] = getattr( telecom, "Telecopie", None ) except Exception: pass contact_dict["est_defaut"] = est_actuellement_defaut logger.info("=" * 80) logger.info(f"[SUCCES] Contact modifie: CT_No={contact_numero}") logger.info( f" Modifications: {', '.join(modifications_appliquees) if modifications_appliquees else 'aucune'}" ) logger.info("=" * 80) return contact_dict except ValueError as e: logger.error(f"[ERREUR VALIDATION] {e}") raise except Exception as e: logger.error(f"[ERREUR] {e}", exc_info=True) raise RuntimeError(f"Erreur technique: {e}") def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info("=" * 80) logger.info( f"[DEFINIR CONTACT PAR DEFAUT] Client={numero}, Contact={contact_numero}" ) logger.info("=" * 80) logger.info("[1] Recuperation infos contact") nom_contact = None prenom_contact = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT CT_Nom, CT_Prenom FROM F_CONTACTT WHERE CT_Num = ? AND CT_No = ?", [numero, contact_numero], ) row = cursor.fetchone() if not row: raise ValueError( f"Contact CT_No={contact_numero} non trouve" ) nom_contact = row.CT_Nom.strip() if row.CT_Nom else "" prenom_contact = row.CT_Prenom.strip() if row.CT_Prenom else "" nom_complet = ( f"{prenom_contact} {nom_contact}".strip() if prenom_contact else nom_contact ) logger.info(f" OK Contact trouve: {nom_complet}") except Exception as e: raise ValueError(f"Contact introuvable: {e}") logger.info("[2] Chargement du client") factory_client = self.cial.CptaApplication.FactoryClient try: persist_client = factory_client.ReadNumero(numero) if not persist_client: raise ValueError(f"Client {numero} non trouve") client = win32com.client.CastTo(persist_client, "IBOClient3") client.Read() logger.info(f" OK Client charge: {client.CT_Intitule}") except Exception as e: raise ValueError(f"Client introuvable: {e}") logger.info("[3] Definition du contact par defaut") ancien_contact = getattr(client, "CT_Contact", "") client.CT_Contact = nom_complet logger.info(f" CT_Contact: '{ancien_contact}' -> '{nom_complet}'") if hasattr(client, "CT_NoContact"): try: client.CT_NoContact = contact_numero logger.info(f" CT_NoContact = {contact_numero}") except Exception: pass logger.info("[4] Enregistrement") try: client.Write() client.Read() logger.info(" OK Client mis a jour") 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 Exception: pass raise RuntimeError(f"Echec mise a jour: {error_detail}") logger.info("=" * 80) logger.info(f"[SUCCES] Contact par defaut: {nom_complet}") logger.info("=" * 80) return { "numero": numero, "contact_numero": contact_numero, "contact_nom": nom_complet, "client_intitule": client.CT_Intitule, "est_defaut": True, "date_modification": datetime.now().isoformat(), } except ValueError as e: logger.error(f"[ERREUR VALIDATION] {e}") raise except Exception as e: logger.error(f"[ERREUR] {e}", exc_info=True) raise RuntimeError(f"Erreur technique: {e}") def lister_contacts(self, numero: str) -> List[Dict]: try: with self._get_sql_connection() as conn: return _get_contacts(numero, conn) except Exception as e: logger.error(f"Erreur liste contacts: {e}") raise RuntimeError(f"Erreur lecture contacts: {str(e)}") def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non établie") try: with self._com_context(), self._lock_com: logger.info("=" * 80) logger.info(f"[SUPPRESSION CONTACT] CT_No={contact_numero}") logger.info("=" * 80) logger.info("[1] Recuperation infos contact") nom_contact = None prenom_contact = None try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT CT_Nom, CT_Prenom FROM F_CONTACTT WHERE CT_Num = ? AND CT_No = ?", [numero, contact_numero], ) row = cursor.fetchone() if not row: raise ValueError( f"Contact CT_No={contact_numero} non trouve" ) nom_contact = row.CT_Nom.strip() if row.CT_Nom else "" prenom_contact = row.CT_Prenom.strip() if row.CT_Prenom else "" logger.info( f" OK Contact trouve: {prenom_contact} {nom_contact}" ) except Exception as e: raise ValueError(f"Contact introuvable: {e}") logger.info("[2] Chargement du contact") factory_dossier = self.cial.CptaApplication.FactoryDossierContact try: persist = factory_dossier.ReadNomPrenom(nom_contact, prenom_contact) if not persist: raise ValueError("Contact non trouvable via ReadNomPrenom") contact = win32com.client.CastTo(persist, "IBOTiersContact3") contact.Read() logger.info(f" OK Contact charge: {contact.Nom}") except Exception as e: raise ValueError(f"Contact introuvable: {e}") logger.info("[3] Suppression") try: contact.Remove() logger.info(" OK Remove() reussi") 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 Exception: pass logger.error(f" ERROR Remove: {error_detail}") raise RuntimeError(f"Echec suppression contact: {error_detail}") logger.info("=" * 80) logger.info( f"[SUCCES] Contact supprime: {prenom_contact} {nom_contact}" ) logger.info("=" * 80) return { "numero": numero, "contact_numero": contact_numero, "nom": nom_contact, "prenom": prenom_contact, "supprime": True, "date_suppression": datetime.now().isoformat(), } except ValueError as e: logger.error(f"[ERREUR VALIDATION] {e}") raise except Exception as e: logger.error(f"[ERREUR] {e}", exc_info=True) raise RuntimeError(f"Erreur technique: {e}") def creer_client(self, client_data: Dict) -> Dict: if not self.cial: raise RuntimeError("Connexion Sage non etablie") try: with self._com_context(), self._lock_com: logger.info("=" * 80) logger.info("[CREATION CLIENT SAGE - DIAGNOSTIC COMPLET]") logger.info("=" * 80) def clean_str(value, max_len: int) -> str: if value is None or str(value).lower() in ("none", "null", ""): return "" return str(value)[:max_len].strip() def safe_int(value, default=None): if value is None: return default try: return int(value) except (ValueError, TypeError): return default def safe_float(value, default=None): if value is None: return default try: return float(value) except (ValueError, TypeError): return default def try_set_attribute(obj, attr_name, value, variants=None): """Essaie de definir un attribut avec plusieurs variantes de noms""" if variants is None: variants = [attr_name] else: variants = [attr_name] + variants for variant in variants: try: if hasattr(obj, variant): setattr(obj, variant, value) logger.debug(f" {variant} = {value} [OK]") return True except Exception as e: logger.debug(f" {variant} echec: {str(e)[:50]}") return False if not client_data.get("intitule"): raise ValueError("intitule obligatoire") if not client_data.get("numero"): raise ValueError("numero obligatoire") intitule = clean_str(client_data["intitule"], 69) numero = clean_str(client_data["numero"], 17).upper() type_tiers = safe_int(client_data.get("type_tiers"), 0) logger.info("[ETAPE 1] CREATION OBJET") factory_map = { 0: ("FactoryClient", "IBOClient3"), 1: ("FactoryFourniss", "IBOFournisseur3"), 2: ("FactorySalarie", "IBOSalarie3"), 3: ("FactoryAutre", "IBOAutre3"), } factory_name, interface_name = factory_map[type_tiers] factory = getattr(self.cial.CptaApplication, factory_name) persist = factory.Create() client = win32com.client.CastTo(persist, interface_name) logger.info(f" Objet cree: {interface_name}") logger.info("[ETAPE 2] CONFIGURATION OBLIGATOIRE") client.CT_Intitule = intitule client.CT_Num = numero logger.info(f" CT_Num = {numero}") logger.info(f" CT_Intitule = {intitule}") qualite = clean_str(client_data.get("qualite", "CLI"), 17) if qualite: client.CT_Qualite = qualite logger.info(f" CT_Qualite = {qualite}") client.SetDefault() logger.info(" SetDefault() applique") if client_data.get("raccourci"): raccourci = clean_str(client_data["raccourci"], 7).upper().strip() try: factory_client = self.cial.CptaApplication.FactoryClient exist_client = factory_client.ReadRaccourci(raccourci) if exist_client: logger.warning( f" CT_Raccourci = {raccourci} [EXISTE DEJA - ignoré]" ) else: client.CT_Raccourci = raccourci logger.info(f" CT_Raccourci = {raccourci} [OK]") except Exception: try: client.CT_Raccourci = raccourci logger.info(f" CT_Raccourci = {raccourci} [OK]") except Exception as e2: logger.warning( f" CT_Raccourci = {raccourci} [ECHEC: {e2}]" ) try: if not hasattr(client, "CT_Type") or client.CT_Type is None: client.CT_Type = type_tiers logger.info(f" CT_Type force a {type_tiers}") except Exception: pass COMPTES_DEFAUT = {0: "4110000", 1: "4010000", 2: "421", 3: "471"} compte = clean_str( client_data.get("compte_general") or COMPTES_DEFAUT.get(type_tiers, "4110000"), 13, ) factory_compte = self.cial.CptaApplication.FactoryCompteG compte_trouve = False comptes_a_tester = [ compte, COMPTES_DEFAUT.get(type_tiers, "4110000"), "4110000", "411000", "411", "4010000", "401000", "401", ] for test_compte in comptes_a_tester: try: persist_compte = factory_compte.ReadNumero(test_compte) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() type_compte = getattr(compte_obj, "CG_Type", None) if type_compte == 0: client.CompteGPrinc = compte_obj compte = test_compte compte_trouve = True logger.info( f" CompteGPrinc = {test_compte} (Type: {type_compte}) [OK]" ) break else: logger.debug( f" Compte {test_compte} - Type {type_compte} incompatible" ) except Exception as e: logger.debug(f" Compte {test_compte} - erreur: {e}") if not compte_trouve: raise RuntimeError("Aucun compte general valide trouve") logger.info(" Configuration categories:") try: factory_cat_tarif = self.cial.CptaApplication.FactoryCategorieTarif for cat_id in ["0", "1"]: try: persist_cat = factory_cat_tarif.ReadIntitule(cat_id) if persist_cat: cat_tarif_obj = win32com.client.CastTo( persist_cat, "IBOCategorieTarif3" ) cat_tarif_obj.Read() client.CatTarif = cat_tarif_obj logger.info(f" CatTarif = {cat_id} [OK]") break except Exception: continue except Exception as e: logger.warning(f" CatTarif erreur: {e}") try: factory_cat_compta = ( self.cial.CptaApplication.FactoryCategorieCompta ) for cat_id in ["0", "1"]: try: persist_cat = factory_cat_compta.ReadIntitule(cat_id) if persist_cat: cat_compta_obj = win32com.client.CastTo( persist_cat, "IBOCategorieCompta3" ) cat_compta_obj.Read() client.CatCompta = cat_compta_obj logger.info(f" CatCompta = {cat_id} [OK]") break except Exception: continue except Exception as e: logger.warning(f" CatCompta erreur: {e}") logger.info("[ETAPE 3] IDENTIFICATION") if client_data.get("classement"): try_set_attribute( client, "CT_Classement", clean_str(client_data["classement"], 17), ) if client_data.get("raccourci"): raccourci = clean_str(client_data["raccourci"], 7).upper() try_set_attribute(client, "CT_Raccourci", raccourci) if client_data.get("siret"): try_set_attribute( client, "CT_Siret", clean_str(client_data["siret"], 15) ) if client_data.get("tva_intra"): try_set_attribute( client, "CT_Identifiant", clean_str(client_data["tva_intra"], 25), ) if client_data.get("code_naf"): try_set_attribute( client, "CT_Ape", clean_str(client_data["code_naf"], 7) ) logger.info("[ETAPE 4] ADRESSE") if client_data.get("contact"): contact_nom = clean_str(client_data["contact"], 35) try: client.CT_Contact = contact_nom logger.info(f" CT_Contact (client) = {contact_nom} [OK]") except Exception as e: logger.warning(f" CT_Contact (client) [ECHEC: {e}]") try: adresse_obj = client.Adresse adresse_obj.Contact = contact_nom logger.info(f" Contact (adresse) = {contact_nom} [OK]") except Exception as e: logger.warning(f" Contact (adresse) [ECHEC: {e}]") try: adresse_obj = client.Adresse logger.info(" Objet Adresse OK") if client_data.get("adresse"): adresse_obj.Adresse = clean_str(client_data["adresse"], 35) if client_data.get("complement"): adresse_obj.Complement = clean_str( client_data["complement"], 35 ) if client_data.get("code_postal"): adresse_obj.CodePostal = clean_str( client_data["code_postal"], 9 ) if client_data.get("ville"): adresse_obj.Ville = clean_str(client_data["ville"], 35) if client_data.get("region"): adresse_obj.CodeRegion = clean_str(client_data["region"], 25) if client_data.get("pays"): adresse_obj.Pays = clean_str(client_data["pays"], 35) except Exception as e: logger.error(f" Adresse erreur: {e}") logger.info("[ETAPE 5] TELECOM") try: telecom_obj = client.Telecom logger.info(" Objet Telecom OK") if client_data.get("telephone"): telecom_obj.Telephone = clean_str(client_data["telephone"], 21) if client_data.get("telecopie"): telecom_obj.Telecopie = clean_str(client_data["telecopie"], 21) if client_data.get("email"): telecom_obj.EMail = clean_str(client_data["email"], 69) if client_data.get("site_web"): telecom_obj.Site = clean_str(client_data["site_web"], 69) if client_data.get("portable"): portable = clean_str(client_data["portable"], 21) try_set_attribute(telecom_obj, "Portable", portable) logger.info(f" Portable = {portable}") if client_data.get("facebook"): facebook = clean_str(client_data["facebook"], 69) if not try_set_attribute(telecom_obj, "Facebook", facebook): try_set_attribute(client, "CT_Facebook", facebook) logger.info(f" Facebook = {facebook}") if client_data.get("linkedin"): linkedin = clean_str(client_data["linkedin"], 69) if not try_set_attribute(telecom_obj, "LinkedIn", linkedin): try_set_attribute(client, "CT_LinkedIn", linkedin) logger.info(f" LinkedIn = {linkedin}") except Exception as e: logger.error(f" Telecom erreur: {e}") logger.info("[ETAPE 6] TAUX") for i in range(1, 5): val = client_data.get(f"taux{i:02d}") if val is not None: try_set_attribute(client, f"CT_Taux{i:02d}", safe_float(val)) logger.info("[ETAPE 7] STATISTIQUES") stat01 = client_data.get("statistique01") or client_data.get("secteur") if stat01: try_set_attribute(client, "CT_Statistique01", clean_str(stat01, 21)) for i in range(2, 11): val = client_data.get(f"statistique{i:02d}") if val: try_set_attribute( client, f"CT_Statistique{i:02d}", clean_str(val, 21) ) logger.info("[ETAPE 8] COMMERCIAL") if client_data.get("encours_autorise"): try_set_attribute( client, "CT_Encours", safe_float(client_data["encours_autorise"]), ) if client_data.get("assurance_credit"): try_set_attribute( client, "CT_Assurance", safe_float(client_data["assurance_credit"]), ) if client_data.get("langue") is not None: try_set_attribute( client, "CT_Langue", safe_int(client_data["langue"]) ) if client_data.get("commercial_code") is not None: co_no = safe_int(client_data["commercial_code"]) if not try_set_attribute(client, "CO_No", co_no): try: factory_collab = ( self.cial.CptaApplication.FactoryCollaborateur ) persist_collab = factory_collab.ReadIntitule(str(co_no)) if persist_collab: collab_obj = win32com.client.CastTo( persist_collab, "IBOCollaborateur3" ) collab_obj.Read() client.Collaborateur = collab_obj logger.debug( f" Collaborateur (objet) = {co_no} [OK]" ) except Exception as e: logger.debug(f" Collaborateur echec: {e}") logger.info("[ETAPE 9] FACTURATION") try_set_attribute( client, "CT_Lettrage", 1 if client_data.get("lettrage_auto", True) else 0, ) try_set_attribute( client, "CT_Sommeil", 0 if client_data.get("est_actif", True) else 1 ) try_set_attribute( client, "CT_Facture", safe_int(client_data.get("type_facture", 1)) ) if client_data.get("est_prospect") is not None: try_set_attribute( client, "CT_Prospect", 1 if client_data["est_prospect"] else 0 ) factu_map = { "CT_BLFact": "bl_en_facture", "CT_Saut": "saut_page", "CT_ValidEch": "validation_echeance", "CT_ControlEnc": "controle_encours", "CT_NotRappel": "exclure_relance", "CT_NotPenal": "exclure_penalites", "CT_BonAPayer": "bon_a_payer", } for attr, key in factu_map.items(): if client_data.get(key) is not None: try_set_attribute(client, attr, safe_int(client_data[key])) logger.info("[ETAPE 10] LOGISTIQUE") logistique_map = { "CT_PrioriteLivr": "priorite_livraison", "CT_LivrPartielle": "livraison_partielle", "CT_DelaiTransport": "delai_transport", "CT_DelaiAppro": "delai_appro", } for attr, key in logistique_map.items(): if client_data.get(key) is not None: try_set_attribute(client, attr, safe_int(client_data[key])) if client_data.get("commentaire"): try_set_attribute( client, "CT_Commentaire", clean_str(client_data["commentaire"], 35), ) logger.info("[ETAPE 12] ANALYTIQUE") if client_data.get("section_analytique"): try_set_attribute( client, "CA_Num", clean_str(client_data["section_analytique"], 13), ) logger.info("[ETAPE 13] ORGANISATION") if client_data.get("mode_reglement_code") is not None: mr_no = safe_int(client_data["mode_reglement_code"]) if not try_set_attribute(client, "MR_No", mr_no): try: factory_mr = self.cial.CptaApplication.FactoryModeRegl persist_mr = factory_mr.ReadIntitule(str(mr_no)) if persist_mr: mr_obj = win32com.client.CastTo( persist_mr, "IBOModeRegl3" ) mr_obj.Read() client.ModeRegl = mr_obj logger.debug(f" ModeRegl (objet) = {mr_no} [OK]") except Exception as e: logger.debug(f" ModeRegl echec: {e}") if client_data.get("surveillance_active") is not None: surveillance = 1 if client_data["surveillance_active"] else 0 try: client.CT_Surveillance = surveillance logger.info(f" CT_Surveillance = {surveillance} [OK]") except Exception as e: logger.warning(f" CT_Surveillance [ECHEC: {e}]") if client_data.get("coface"): coface = clean_str(client_data["coface"], 25) try: client.CT_Coface = coface logger.info(f" CT_Coface = {coface} [OK]") except Exception as e: logger.warning(f" CT_Coface [ECHEC: {e}]") if client_data.get("forme_juridique"): try_set_attribute( client, "CT_SvFormeJuri", clean_str(client_data["forme_juridique"], 33), ) if client_data.get("effectif"): try_set_attribute( client, "CT_SvEffectif", clean_str(client_data["effectif"], 11) ) if client_data.get("sv_regularite"): try_set_attribute( client, "CT_SvRegul", clean_str(client_data["sv_regularite"], 3) ) if client_data.get("sv_cotation"): try_set_attribute( client, "CT_SvCotation", clean_str(client_data["sv_cotation"], 5), ) if client_data.get("sv_objet_maj"): try_set_attribute( client, "CT_SvObjetMaj", clean_str(client_data["sv_objet_maj"], 61), ) ca = client_data.get("ca_annuel") or client_data.get( "sv_chiffre_affaires" ) if ca: try_set_attribute(client, "CT_SvCA", safe_float(ca)) if client_data.get("sv_resultat"): try_set_attribute( client, "CT_SvResultat", safe_float(client_data["sv_resultat"]) ) logger.info("=" * 80) logger.info("[DIAGNOSTIC PRE-WRITE]") champs_diagnostic = [ "CT_Num", "CT_Intitule", "CT_Type", "CT_Qualite", "CT_Facture", "CT_Lettrage", "CT_Sommeil", ] for champ in champs_diagnostic: try: valeur = getattr(client, champ, "ATTRIBUT_INEXISTANT") logger.info(f" {champ}: {valeur}") except Exception as e: logger.info(f" {champ}: ERREUR ({str(e)[:50]})") try: compte_obj = client.CompteGPrinc if compte_obj: logger.info(f" CompteGPrinc.CG_Num: {compte_obj.CG_Num}") logger.info(f" CompteGPrinc.CG_Type: {compte_obj.CG_Type}") else: logger.error(" CompteGPrinc: NULL !!!") except Exception as e: logger.error(f" CompteGPrinc: ERREUR - {e}") logger.info("=" * 80) logger.info("[WRITE]") try: client.Write() client.Read() logger.info("[OK] Write reussi") 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 Exception: pass logger.error(f"[ERREUR] {error_detail}") raise RuntimeError(f"Echec Write(): {error_detail}") num_final = getattr(client, "CT_Num", numero) logger.info("=" * 80) logger.info(f"[SUCCES] CLIENT CREE: {num_final}") logger.info("=" * 80) return { "numero": num_final, "intitule": intitule, "type_tiers": type_tiers, "qualite": qualite, "compte_general": compte, "date_creation": datetime.now().isoformat(), } except ValueError as e: logger.error(f"[ERREUR VALIDATION] {e}") raise except Exception as e: logger.error(f"[ERREUR] {e}", exc_info=True) raise RuntimeError(f"Erreur technique: {e}") 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: logger.info("=" * 80) logger.info(f"[MODIFICATION CLIENT SAGE - {code}]") logger.info("=" * 80) def clean_str(value, max_len: int) -> str: if value is None or str(value).lower() in ("none", "null", ""): return "" return str(value)[:max_len].strip() def safe_int(value, default=None): if value is None: return default try: return int(value) except (ValueError, TypeError): return default def safe_float(value, default=None): if value is None: return default try: return float(value) except (ValueError, TypeError): return default def try_set_attribute(obj, attr_name, value, variants=None): """Essaie de définir un attribut avec plusieurs variantes de noms""" if variants is None: variants = [attr_name] else: variants = [attr_name] + variants for variant in variants: try: if hasattr(obj, variant): setattr(obj, variant, value) logger.debug(f" {variant} = {value} [OK]") return True except Exception as e: logger.debug(f" {variant} echec: {str(e)[:50]}") return False champs_modifies = [] logger.info("[ETAPE 1] CHARGEMENT CLIENT") 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 chargé: {getattr(client, 'CT_Intitule', '')}") logger.info("[ETAPE 2] IDENTIFICATION") if "intitule" in client_data: intitule = clean_str(client_data["intitule"], 69) client.CT_Intitule = intitule champs_modifies.append("intitule") logger.info(f" CT_Intitule = {intitule}") if "qualite" in client_data: qualite = clean_str(client_data["qualite"], 17) if try_set_attribute(client, "CT_Qualite", qualite): champs_modifies.append("qualite") if "classement" in client_data: if try_set_attribute( client, "CT_Classement", clean_str(client_data["classement"], 17), ): champs_modifies.append("classement") if "raccourci" in client_data: raccourci = clean_str(client_data["raccourci"], 7).upper() try: exist_client = factory_client.ReadRaccourci(raccourci) if exist_client and exist_client.CT_Num != code: logger.warning( f" CT_Raccourci = {raccourci} [EXISTE DEJA - ignoré]" ) else: if try_set_attribute(client, "CT_Raccourci", raccourci): champs_modifies.append("raccourci") except Exception: if try_set_attribute(client, "CT_Raccourci", raccourci): champs_modifies.append("raccourci") if "siret" in client_data: if try_set_attribute( client, "CT_Siret", clean_str(client_data["siret"], 15) ): champs_modifies.append("siret") if "tva_intra" in client_data: if try_set_attribute( client, "CT_Identifiant", clean_str(client_data["tva_intra"], 25), ): champs_modifies.append("tva_intra") if "code_naf" in client_data: if try_set_attribute( client, "CT_Ape", clean_str(client_data["code_naf"], 7) ): champs_modifies.append("code_naf") adresse_keys = [ "contact", "adresse", "complement", "code_postal", "ville", "region", "pays", ] if any(k in client_data for k in adresse_keys): logger.info("[ETAPE 3] ADRESSE") try: if "contact" in client_data: contact_nom = clean_str(client_data["contact"], 35) try: client.CT_Contact = contact_nom champs_modifies.append("contact (client)") logger.info( f" CT_Contact (client) = {contact_nom} [OK]" ) except Exception as e: logger.warning(f" CT_Contact (client) [ECHEC: {e}]") try: adresse_obj = client.Adresse adresse_obj.Contact = contact_nom champs_modifies.append("contact (adresse)") logger.info(f" Contact (adresse) = {contact_nom} [OK]") except Exception as e: logger.warning(f" Contact (adresse) [ECHEC: {e}]") adresse_obj = client.Adresse if "adresse" in client_data: adresse_obj.Adresse = clean_str(client_data["adresse"], 35) champs_modifies.append("adresse") if "complement" in client_data: adresse_obj.Complement = clean_str( client_data["complement"], 35 ) champs_modifies.append("complement") if "code_postal" in client_data: adresse_obj.CodePostal = clean_str( client_data["code_postal"], 9 ) champs_modifies.append("code_postal") if "ville" in client_data: adresse_obj.Ville = clean_str(client_data["ville"], 35) champs_modifies.append("ville") if "region" in client_data: adresse_obj.CodeRegion = clean_str( client_data["region"], 25 ) champs_modifies.append("region") if "pays" in client_data: adresse_obj.Pays = clean_str(client_data["pays"], 35) champs_modifies.append("pays") logger.info( f" Adresse mise à jour ({len([k for k in adresse_keys if k in client_data])} champs)" ) except Exception as e: logger.error(f" Adresse erreur: {e}") telecom_keys = [ "telephone", "telecopie", "email", "site_web", "portable", "facebook", "linkedin", ] if any(k in client_data for k in telecom_keys): logger.info("[ETAPE 4] TELECOM") try: telecom_obj = client.Telecom if "telephone" in client_data: telecom_obj.Telephone = clean_str( client_data["telephone"], 21 ) champs_modifies.append("telephone") if "telecopie" in client_data: telecom_obj.Telecopie = clean_str( client_data["telecopie"], 21 ) champs_modifies.append("telecopie") if "email" in client_data: telecom_obj.EMail = clean_str(client_data["email"], 69) champs_modifies.append("email") if "site_web" in client_data: telecom_obj.Site = clean_str(client_data["site_web"], 69) champs_modifies.append("site_web") if "portable" in client_data: portable = clean_str(client_data["portable"], 21) if try_set_attribute(telecom_obj, "Portable", portable): champs_modifies.append("portable") if "facebook" in client_data: facebook = clean_str(client_data["facebook"], 69) if not try_set_attribute(telecom_obj, "Facebook", facebook): try_set_attribute(client, "CT_Facebook", facebook) champs_modifies.append("facebook") if "linkedin" in client_data: linkedin = clean_str(client_data["linkedin"], 69) if not try_set_attribute(telecom_obj, "LinkedIn", linkedin): try_set_attribute(client, "CT_LinkedIn", linkedin) champs_modifies.append("linkedin") logger.info( f" Telecom mis à jour ({len([k for k in telecom_keys if k in client_data])} champs)" ) except Exception as e: logger.error(f" Telecom erreur: {e}") if "compte_general" in client_data: logger.info("[ETAPE 5] COMPTE GENERAL") compte = clean_str(client_data["compte_general"], 13) factory_compte = self.cial.CptaApplication.FactoryCompteG try: persist_compte = factory_compte.ReadNumero(compte) if persist_compte: compte_obj = win32com.client.CastTo( persist_compte, "IBOCompteG3" ) compte_obj.Read() type_compte = getattr(compte_obj, "CG_Type", None) if type_compte == 0: client.CompteGPrinc = compte_obj champs_modifies.append("compte_general") logger.info(f" CompteGPrinc = {compte} [OK]") else: logger.warning( f" Compte {compte} - Type {type_compte} incompatible" ) except Exception as e: logger.warning(f" CompteGPrinc erreur: {e}") if ( "categorie_tarifaire" in client_data or "categorie_comptable" in client_data ): logger.info("[ETAPE 6] CATEGORIES") if "categorie_tarifaire" in client_data: try: cat_id = str(client_data["categorie_tarifaire"]) factory_cat_tarif = ( self.cial.CptaApplication.FactoryCategorieTarif ) persist_cat = factory_cat_tarif.ReadIntitule(cat_id) if persist_cat: cat_tarif_obj = win32com.client.CastTo( persist_cat, "IBOCategorieTarif3" ) cat_tarif_obj.Read() client.CatTarif = cat_tarif_obj champs_modifies.append("categorie_tarifaire") logger.info(f" CatTarif = {cat_id} [OK]") except Exception as e: logger.warning(f" CatTarif erreur: {e}") if "categorie_comptable" in client_data: try: cat_id = str(client_data["categorie_comptable"]) factory_cat_compta = ( self.cial.CptaApplication.FactoryCategorieCompta ) persist_cat = factory_cat_compta.ReadIntitule(cat_id) if persist_cat: cat_compta_obj = win32com.client.CastTo( persist_cat, "IBOCategorieCompta3" ) cat_compta_obj.Read() client.CatCompta = cat_compta_obj champs_modifies.append("categorie_comptable") logger.info(f" CatCompta = {cat_id} [OK]") except Exception as e: logger.warning(f" CatCompta erreur: {e}") taux_modifies = False for i in range(1, 5): key = f"taux{i:02d}" if key in client_data: if not taux_modifies: logger.info("[ETAPE 7] TAUX") taux_modifies = True val = safe_float(client_data[key]) if try_set_attribute(client, f"CT_Taux{i:02d}", val): champs_modifies.append(key) stat_modifies = False stat01 = client_data.get("statistique01") or client_data.get("secteur") if stat01: if not stat_modifies: logger.info("[ETAPE 8] STATISTIQUES") stat_modifies = True if try_set_attribute( client, "CT_Statistique01", clean_str(stat01, 21) ): champs_modifies.append("statistique01") for i in range(2, 11): key = f"statistique{i:02d}" if key in client_data: if not stat_modifies: logger.info("[ETAPE 8] STATISTIQUES") stat_modifies = True if try_set_attribute( client, f"CT_Statistique{i:02d}", clean_str(client_data[key], 21), ): champs_modifies.append(key) commercial_keys = [ "encours_autorise", "assurance_credit", "langue", "commercial_code", ] if any(k in client_data for k in commercial_keys): logger.info("[ETAPE 9] COMMERCIAL") if "encours_autorise" in client_data: if try_set_attribute( client, "CT_Encours", safe_float(client_data["encours_autorise"]), ): champs_modifies.append("encours_autorise") if "assurance_credit" in client_data: if try_set_attribute( client, "CT_Assurance", safe_float(client_data["assurance_credit"]), ): champs_modifies.append("assurance_credit") if "langue" in client_data: if try_set_attribute( client, "CT_Langue", safe_int(client_data["langue"]) ): champs_modifies.append("langue") if "commercial_code" in client_data: co_no = safe_int(client_data["commercial_code"]) if not try_set_attribute(client, "CO_No", co_no): try: factory_collab = ( self.cial.CptaApplication.FactoryCollaborateur ) persist_collab = factory_collab.ReadIntitule(str(co_no)) if persist_collab: collab_obj = win32com.client.CastTo( persist_collab, "IBOCollaborateur3" ) collab_obj.Read() client.Collaborateur = collab_obj champs_modifies.append("commercial_code") logger.info(f" Collaborateur = {co_no} [OK]") except Exception as e: logger.warning(f" Collaborateur erreur: {e}") facturation_keys = [ "lettrage_auto", "est_actif", "type_facture", "est_prospect", "bl_en_facture", "saut_page", "validation_echeance", "controle_encours", "exclure_relance", "exclure_penalites", "bon_a_payer", ] if any(k in client_data for k in facturation_keys): logger.info("[ETAPE 10] FACTURATION") if "lettrage_auto" in client_data: if try_set_attribute( client, "CT_Lettrage", 1 if client_data["lettrage_auto"] else 0, ): champs_modifies.append("lettrage_auto") if "est_actif" in client_data: if try_set_attribute( client, "CT_Sommeil", 0 if client_data["est_actif"] else 1 ): champs_modifies.append("est_actif") if "type_facture" in client_data: if try_set_attribute( client, "CT_Facture", safe_int(client_data["type_facture"]) ): champs_modifies.append("type_facture") if "est_prospect" in client_data: if try_set_attribute( client, "CT_Prospect", 1 if client_data["est_prospect"] else 0, ): champs_modifies.append("est_prospect") factu_map = { "CT_BLFact": "bl_en_facture", "CT_Saut": "saut_page", "CT_ValidEch": "validation_echeance", "CT_ControlEnc": "controle_encours", "CT_NotRappel": "exclure_relance", "CT_NotPenal": "exclure_penalites", "CT_BonAPayer": "bon_a_payer", } for attr, key in factu_map.items(): if key in client_data: if try_set_attribute( client, attr, safe_int(client_data[key]) ): champs_modifies.append(key) logistique_keys = [ "priorite_livraison", "livraison_partielle", "delai_transport", "delai_appro", ] if any(k in client_data for k in logistique_keys): logger.info("[ETAPE 11] LOGISTIQUE") logistique_map = { "CT_PrioriteLivr": "priorite_livraison", "CT_LivrPartielle": "livraison_partielle", "CT_DelaiTransport": "delai_transport", "CT_DelaiAppro": "delai_appro", } for attr, key in logistique_map.items(): if key in client_data: if try_set_attribute( client, attr, safe_int(client_data[key]) ): champs_modifies.append(key) if "commentaire" in client_data: logger.info("[ETAPE 12] COMMENTAIRE") if try_set_attribute( client, "CT_Commentaire", clean_str(client_data["commentaire"], 35), ): champs_modifies.append("commentaire") if "section_analytique" in client_data: logger.info("[ETAPE 13] ANALYTIQUE") if try_set_attribute( client, "CA_Num", clean_str(client_data["section_analytique"], 13), ): champs_modifies.append("section_analytique") organisation_keys = [ "mode_reglement_code", "surveillance_active", "coface", "forme_juridique", "effectif", "sv_regularite", "sv_cotation", "sv_objet_maj", "ca_annuel", "sv_chiffre_affaires", "sv_resultat", ] if any(k in client_data for k in organisation_keys): logger.info("[ETAPE 14] ORGANISATION & SURVEILLANCE") if "mode_reglement_code" in client_data: mr_no = safe_int(client_data["mode_reglement_code"]) if not try_set_attribute(client, "MR_No", mr_no): try: factory_mr = self.cial.CptaApplication.FactoryModeRegl persist_mr = factory_mr.ReadIntitule(str(mr_no)) if persist_mr: mr_obj = win32com.client.CastTo( persist_mr, "IBOModeRegl3" ) mr_obj.Read() client.ModeRegl = mr_obj champs_modifies.append("mode_reglement_code") logger.info(f" ModeRegl = {mr_no} [OK]") except Exception as e: logger.warning(f" ModeRegl erreur: {e}") if "surveillance_active" in client_data: surveillance = 1 if client_data["surveillance_active"] else 0 try: client.CT_Surveillance = surveillance champs_modifies.append("surveillance_active") logger.info(f" CT_Surveillance = {surveillance} [OK]") except Exception as e: logger.warning(f" CT_Surveillance [ECHEC: {e}]") if "coface" in client_data: coface = clean_str(client_data["coface"], 25) try: client.CT_Coface = coface champs_modifies.append("coface") logger.info(f" CT_Coface = {coface} [OK]") except Exception as e: logger.warning(f" CT_Coface [ECHEC: {e}]") if "forme_juridique" in client_data: if try_set_attribute( client, "CT_SvFormeJuri", clean_str(client_data["forme_juridique"], 33), ): champs_modifies.append("forme_juridique") if "effectif" in client_data: if try_set_attribute( client, "CT_SvEffectif", clean_str(client_data["effectif"], 11), ): champs_modifies.append("effectif") if "sv_regularite" in client_data: if try_set_attribute( client, "CT_SvRegul", clean_str(client_data["sv_regularite"], 3), ): champs_modifies.append("sv_regularite") if "sv_cotation" in client_data: if try_set_attribute( client, "CT_SvCotation", clean_str(client_data["sv_cotation"], 5), ): champs_modifies.append("sv_cotation") if "sv_objet_maj" in client_data: if try_set_attribute( client, "CT_SvObjetMaj", clean_str(client_data["sv_objet_maj"], 61), ): champs_modifies.append("sv_objet_maj") ca = client_data.get("ca_annuel") or client_data.get( "sv_chiffre_affaires" ) if ca: if try_set_attribute(client, "CT_SvCA", safe_float(ca)): champs_modifies.append("ca_annuel/sv_chiffre_affaires") if "sv_resultat" in client_data: if try_set_attribute( client, "CT_SvResultat", safe_float(client_data["sv_resultat"]), ): champs_modifies.append("sv_resultat") if not champs_modifies: logger.warning("Aucun champ à modifier") return _extraire_client(client) logger.info("=" * 80) logger.info(f"[WRITE] {len(champs_modifies)} champs modifiés:") for i, champ in enumerate(champs_modifies, 1): logger.info(f" {i}. {champ}") logger.info("=" * 80) try: client.Write() client.Read() 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 Exception: pass logger.error(f"[ERREUR] {error_detail}") raise RuntimeError(f"Echec Write(): {error_detail}") logger.info("=" * 80) logger.info( f"[SUCCES] CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs)" ) logger.info("=" * 80) return _extraire_client(client) except ValueError as e: logger.error(f"[ERREUR VALIDATION] {e}") raise except Exception as e: logger.error(f"[ERREUR] {e}", exc_info=True) raise RuntimeError(f"Erreur technique: {e}") def creer_commande_enrichi(self, commande_data: dict) -> Dict: """Crée une commande""" return creer_document_vente(self, commande_data, TypeDocumentVente.COMMANDE) def modifier_commande(self, numero: str, commande_data: dict) -> Dict: """Modifie une commande""" return modifier_document_vente( self, numero, commande_data, TypeDocumentVente.COMMANDE ) def creer_livraison_enrichi(self, livraison_data: dict) -> Dict: """Crée un bon de livraison""" return creer_document_vente(self, livraison_data, TypeDocumentVente.LIVRAISON) def modifier_livraison(self, numero: str, livraison_data: dict) -> Dict: """Modifie un bon de livraison""" return modifier_document_vente( self, numero, livraison_data, TypeDocumentVente.LIVRAISON ) def creer_avoir_enrichi(self, avoir_data: dict) -> Dict: """Crée un avoir""" return creer_document_vente(self, avoir_data, TypeDocumentVente.AVOIR) def modifier_avoir(self, numero: str, avoir_data: dict) -> Dict: """Modifie un avoir""" return modifier_document_vente( self, numero, avoir_data, TypeDocumentVente.AVOIR ) def creer_facture_enrichi(self, facture_data: dict) -> Dict: """Crée une facture""" return creer_document_vente(self, facture_data, TypeDocumentVente.FACTURE) def modifier_facture(self, numero: str, facture_data: dict) -> Dict: """Modifie une facture""" return modifier_document_vente( self, numero, facture_data, TypeDocumentVente.FACTURE ) def creer_article(self, article_data: dict) -> dict: """Crée un article dans Sage 100 - Version fusionnée complète""" with self._com_context(), self._lock_com: try: logger.info("[ARTICLE] === CREATION ARTICLE ===") # === Validation données === valide, erreur = valider_donnees_creation(article_data) if not valide: raise ValueError(erreur) 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: # === Découverte 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']})" ) # === Extraction et validation des données === reference = article_data.get("reference", "").upper().strip() designation = article_data.get("designation", "").strip() if len(designation) > 69: designation = designation[:69] 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}") # === Vérifier si article existe === 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" not in error_msg and "-2607" not in error_msg ): raise # === Créer l'article === persist = factory.Create() article = win32com.client.CastTo(persist, "IBOArticle3") article.SetDefault() article.AR_Ref = reference article.AR_Design = designation # === Recherche article modèle === logger.info("[MODELE] Recherche article modèle...") 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 = _safe_strip(row.AR_Ref) logger.info( f" [SQL] Article modèle : {article_modele_ref}" ) except Exception as e: logger.warning(f" [SQL] Erreur recherche : {e}") 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] Modèle chargé : {article_modele_ref}" ) except Exception as e: logger.warning(f" [WARN] Erreur chargement : {e}") article_modele = None if not article_modele: raise ValueError( "Aucun article modèle trouvé. Créez au moins un article dans Sage." ) # === Copie Unite depuis modèle === logger.info("[UNITE] Copie Unite depuis modèle...") unite_trouvee = False try: unite_obj = getattr(article_modele, "Unite", None) if unite_obj: article.Unite = unite_obj logger.info(" [OK] Unite copiée") unite_trouvee = True except Exception as e: logger.warning(f" Unite non copiable : {e}") if not unite_trouvee: raise ValueError( "Impossible de copier l'unité depuis le modèle" ) # === Gestion famille === famille_trouvee = False famille_code_personnalise = article_data.get("famille") if famille_code_personnalise: logger.info( f" [FAMILLE] Code demandé : {famille_code_personnalise}" ) try: famille_code_exact = None 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 = _safe_strip(row.FA_CodeFamille) if row.FA_Type == 1: raise ValueError( f"Famille '{famille_code_personnalise}' est de type Total" ) logger.info( f" [SQL] Famille trouvée : {famille_code_exact}" ) else: raise ValueError( f"Famille '{famille_code_personnalise}' introuvable" ) if famille_code_exact: factory_famille = self.cial.FactoryFamille famille_obj = None 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_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 à index {index}" ) break index += 1 except Exception: break if famille_obj: famille_obj.Read() article.Famille = famille_obj else: raise ValueError( f"Famille '{famille_code_personnalise}' inaccessible via COM" ) except Exception as e: logger.error(f" [ERREUR] Famille : {e}") raise if not famille_trouvee: try: famille_obj = getattr(article_modele, "Famille", None) if famille_obj: article.Famille = famille_obj logger.info(" [OK] Famille copiée depuis modèle") famille_trouvee = True except Exception as e: logger.debug(f" Famille non copiable : {e}") # === Champs obligatoires depuis modèle === logger.info("[CHAMPS] Copie champs obligatoires...") 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)) article.AR_SuiviStock = 2 logger.info(" [OK] Champs de base copiés (AR_SuiviStock=2)") # === Application des champs fournis === logger.info("[CHAMPS] Application champs fournis...") champs_appliques = [] champs_echoues = [] # Prix de vente if "prix_vente" in article_data: try: article.AR_PrixVen = float(article_data["prix_vente"]) champs_appliques.append("prix_vente") logger.info( f" ✓ prix_vente = {article_data['prix_vente']}" ) except Exception as e: champs_echoues.append(f"prix_vente: {e}") # Prix d'achat if "prix_achat" in article_data: try: article.AR_PrixAchat = float(article_data["prix_achat"]) champs_appliques.append("prix_achat") logger.info( f" ✓ prix_achat = {article_data['prix_achat']}" ) except Exception as e: champs_echoues.append(f"prix_achat: {e}") # Coefficient if "coef" in article_data: try: article.AR_Coef = float(article_data["coef"]) champs_appliques.append("coef") logger.info(f" ✓ coef = {article_data['coef']}") except Exception as e: champs_echoues.append(f"coef: {e}") # Code EAN if "code_ean" in article_data: try: article.AR_CodeBarre = str(article_data["code_ean"]) champs_appliques.append("code_ean") logger.info(f" ✓ code_ean = {article_data['code_ean']}") except Exception as e: champs_echoues.append(f"code_ean: {e}") # Description -> AR_Langue1 if "description" in article_data: try: article.AR_Langue1 = str(article_data["description"])[:255] champs_appliques.append("description") logger.info(" ✓ description définie (AR_Langue1)") except Exception as e: champs_echoues.append(f"description: {e}") # Pays if "pays" in article_data: try: article.AR_Pays = str(article_data["pays"])[:3].upper() champs_appliques.append("pays") logger.info(f" ✓ pays = {article_data['pays']}") except Exception as e: champs_echoues.append(f"pays: {e}") # Garantie if "garantie" in article_data: try: article.AR_Garantie = int(article_data["garantie"]) champs_appliques.append("garantie") logger.info(f" ✓ garantie = {article_data['garantie']}") except Exception as e: champs_echoues.append(f"garantie: {e}") # Délai if "delai" in article_data: try: article.AR_Delai = int(article_data["delai"]) champs_appliques.append("delai") logger.info(f" ✓ delai = {article_data['delai']}") except Exception as e: champs_echoues.append(f"delai: {e}") # Poids net if "poids_net" in article_data: try: article.AR_PoidsNet = float(article_data["poids_net"]) champs_appliques.append("poids_net") logger.info(f" ✓ poids_net = {article_data['poids_net']}") except Exception as e: champs_echoues.append(f"poids_net: {e}") # Poids brut if "poids_brut" in article_data: try: article.AR_PoidsBrut = float(article_data["poids_brut"]) champs_appliques.append("poids_brut") logger.info( f" ✓ poids_brut = {article_data['poids_brut']}" ) except Exception as e: champs_echoues.append(f"poids_brut: {e}") # Code fiscal if "code_fiscal" in article_data: try: article.AR_CodeFiscal = str(article_data["code_fiscal"])[ :10 ] champs_appliques.append("code_fiscal") logger.info( f" ✓ code_fiscal = {article_data['code_fiscal']}" ) except Exception as e: champs_echoues.append(f"code_fiscal: {e}") # Soumis escompte if "soumis_escompte" in article_data: try: article.AR_Escompte = ( 1 if article_data["soumis_escompte"] else 0 ) champs_appliques.append("soumis_escompte") logger.info( f" ✓ soumis_escompte = {article_data['soumis_escompte']}" ) except Exception as e: champs_echoues.append(f"soumis_escompte: {e}") # Publié if "publie" in article_data: try: article.AR_Publie = 1 if article_data["publie"] else 0 champs_appliques.append("publie") logger.info(f" ✓ publie = {article_data['publie']}") except Exception as e: champs_echoues.append(f"publie: {e}") # En sommeil if "en_sommeil" in article_data: try: article.AR_Sommeil = 1 if article_data["en_sommeil"] else 0 champs_appliques.append("en_sommeil") logger.info( f" ✓ en_sommeil = {article_data['en_sommeil']}" ) except Exception as e: champs_echoues.append(f"en_sommeil: {e}") if champs_echoues: logger.warning( f"[WARN] Champs échoués : {', '.join(champs_echoues)}" ) logger.info( f"[CHAMPS] Appliqués : {len(champs_appliques)} / Échoués : {len(champs_echoues)}" ) # === Écriture dans Sage === 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 Exception: pass logger.error(f" [ERREUR] Write() : {error_detail}") raise RuntimeError(f"Échec création : {error_detail}") # === Statistiques (AR_Stat après Write) === stats_a_definir = [] for i in range(1, 6): stat_key = f"stat_0{i}" if stat_key in article_data and article_data[stat_key]: stats_a_definir.append( (i - 1, str(article_data[stat_key])[:20]) ) if stats_a_definir: logger.info("[STATS] Définition statistiques via AR_Stat()...") try: for index, value in stats_a_definir: article.AR_Stat(index, value) logger.info(f" ✓ stat_0{index + 1} = {value}") champs_appliques.append(f"stat_0{index + 1}") article.Write() logger.info(" [OK] Statistiques sauvegardées") except Exception as e: logger.warning(f" ⚠ Statistiques : {e}") # === Commit transaction === if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info("[COMMIT] Transaction committée") except Exception as e: logger.warning(f"[COMMIT] Erreur : {e}") # === Gestion stocks === stock_defini = False has_stock_values = stock_reel or stock_mini or stock_maxi if has_stock_values: logger.info( f"[STOCK] Définition stock (dépôt '{depot_a_utiliser['code']}')..." ) # Méthode 1 : Créer via COM if stock_reel: try: depot_obj = depot_a_utiliser["objet"] 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 : {factory_name}") break except Exception: continue if factory_stock: stock_persist = factory_stock.Create() stock_obj = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) stock_obj.SetDefault() stock_obj.AR_Ref = reference stock_obj.AS_QteSto = float(stock_reel) if stock_mini: try: stock_obj.AS_QteMini = float(stock_mini) except Exception as e: logger.warning(f" AS_QteMini : {e}") if stock_maxi: try: stock_obj.AS_QteMaxi = float(stock_maxi) except Exception as e: logger.warning(f" AS_QteMaxi : {e}") stock_obj.Write() stock_defini = True logger.info( f" [OK] Stock défini via COM : réel={stock_reel}, min={stock_mini}, max={stock_maxi}" ) except Exception as e: logger.warning(f" [WARN] Stock COM : {e}") # Méthode 2 : Mise à jour SQL si COM échoue ou pour mini/maxi seulement if (stock_mini or stock_maxi) and not stock_defini: try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT TOP 1 DE_No FROM F_DEPOT ORDER BY DE_No" ) row = cursor.fetchone() if row: depot_no = row.DE_No cursor.execute( """ SELECT COUNT(*) FROM F_ARTSTOCK WHERE AR_Ref = ? AND DE_No = ? """, (reference.upper(), depot_no), ) count = cursor.fetchone()[0] if count > 0: update_parts = [] params = [] if stock_mini: update_parts.append("AS_QteMini = ?") params.append(float(stock_mini)) if stock_maxi: update_parts.append("AS_QteMaxi = ?") params.append(float(stock_maxi)) if update_parts: params.extend( [reference.upper(), depot_no] ) cursor.execute( f""" UPDATE F_ARTSTOCK SET {", ".join(update_parts)} WHERE AR_Ref = ? AND DE_No = ? """, params, ) conn.commit() logger.info( " [SQL] Stocks mini/maxi mis à jour" ) else: cursor.execute( """ INSERT INTO F_ARTSTOCK (AR_Ref, DE_No, AS_QteSto, AS_QteMini, AS_QteMaxi) VALUES (?, ?, ?, ?, ?) """, ( reference.upper(), depot_no, float(stock_reel) if stock_reel else 0.0, float(stock_mini) if stock_mini else 0.0, float(stock_maxi) if stock_maxi else 0.0, ), ) conn.commit() logger.info(" [SQL] Ligne stock créée") except Exception as e: logger.error(f"[STOCK] Erreur SQL : {e}") # === Construction réponse depuis SQL === logger.info("[RESPONSE] Construction réponse depuis SQL...") try: with self._get_sql_connection() as conn: cursor = conn.cursor() # Lecture complète article cursor.execute( """ SELECT a.AR_Ref, a.AR_Design, a.AR_PrixVen, a.AR_PrixAch, a.AR_Coef, a.AR_CodeBarre, a.AR_CodeFiscal, a.AR_Pays, a.AR_Garantie, a.AR_Delai, a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_Escompte, a.AR_Publie, a.AR_Sommeil, a.FA_CodeFamille, f.FA_Intitule, a.AR_Type, a.AR_UniteVen, a.AR_Langue1, a.AR_Stat01, a.AR_Stat02, a.AR_Stat03, a.AR_Stat04, a.AR_Stat05 FROM F_ARTICLE a LEFT JOIN F_FAMILLE f ON a.FA_CodeFamille = f.FA_CodeFamille WHERE a.AR_Ref = ? """, (reference.upper(),), ) row = cursor.fetchone() if row: resultat = { "reference": _safe_strip(row[0]), "designation": _safe_strip(row[1]), "prix_vente": float(row[2]) if row[2] else 0.0, "prix_achat": float(row[3]) if row[3] else 0.0, "coef": float(row[4]) if row[4] else None, "code_ean": _safe_strip(row[5]), "code_barre": _safe_strip(row[5]), "code_fiscal": _safe_strip(row[6]), "pays": _safe_strip(row[7]), "garantie": int(row[8]) if row[8] else None, "delai": int(row[9]) if row[9] else None, "poids_net": float(row[10]) if row[10] else None, "poids_brut": float(row[11]) if row[11] else None, "soumis_escompte": bool(row[12]) if row[12] is not None else None, "publie": bool(row[13]) if row[13] is not None else None, "en_sommeil": bool(row[14]) if row[14] else False, "est_actif": not bool(row[14]) if row[14] is not None else True, "famille_code": _safe_strip(row[15]), "famille_libelle": _safe_strip(row[16]) if row[16] else "", "type_article": int(row[17]) if row[17] is not None else 0, "type_article_libelle": "Article" if not row[17] else None, "unite_vente": _safe_strip(row[18]) if row[18] else None, "description": _safe_strip(row[19]) if row[19] else None, "stat_01": _safe_strip(row[20]) if row[20] else None, "stat_02": _safe_strip(row[21]) if row[21] else None, "stat_03": _safe_strip(row[22]) if row[22] else None, "stat_04": _safe_strip(row[23]) if row[23] else None, "stat_05": _safe_strip(row[24]) if row[24] else None, } # Lecture stocks cursor.execute( """ SELECT s.AS_QteSto, s.AS_QteMini, s.AS_QteMaxi, s.AS_QteRes, s.AS_QteCom FROM F_ARTSTOCK s WHERE s.AR_Ref = ? """, (reference.upper(),), ) stock_total = 0.0 stock_mini_val = 0.0 stock_maxi_val = 0.0 stock_reserve_val = 0.0 stock_commande_val = 0.0 stock_row = cursor.fetchone() if stock_row: stock_total = ( float(stock_row[0]) if stock_row[0] else 0.0 ) stock_mini_val = ( float(stock_row[1]) if stock_row[1] else 0.0 ) stock_maxi_val = ( float(stock_row[2]) if stock_row[2] else 0.0 ) stock_reserve_val = ( float(stock_row[3]) if stock_row[3] else 0.0 ) stock_commande_val = ( float(stock_row[4]) if stock_row[4] else 0.0 ) resultat["stock_reel"] = stock_total resultat["stock_mini"] = stock_mini_val resultat["stock_maxi"] = stock_maxi_val resultat["stock_disponible"] = ( stock_total - stock_reserve_val ) resultat["stock_reserve"] = stock_reserve_val resultat["stock_commande"] = stock_commande_val logger.info( f"[OK] ARTICLE CREE : {reference} - Stock : {stock_total}" ) return resultat except Exception as e: logger.warning(f"[RESPONSE] Erreur lecture SQL : {e}") # Fallback sur extraction COM si SQL échoue logger.info("[FALLBACK] Extraction COM...") article_cree_persist = factory.ReadReference(reference) if not article_cree_persist: raise RuntimeError("Article créé mais introuvable") article_cree = win32com.client.CastTo( article_cree_persist, "IBOArticle3" ) article_cree.Read() resultat = _extraire_article(article_cree) if not resultat: resultat = {"reference": reference, "designation": designation} # Forcer les valeurs connues for key in [ "prix_vente", "prix_achat", "coef", "stock_mini", "stock_maxi", "code_ean", "code_fiscal", "pays", "garantie", "delai", "poids_net", "poids_brut", "soumis_escompte", "publie", ]: if key in article_data and article_data[key] is not None: resultat[key] = article_data[key] return resultat except ValueError: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except Exception: pass raise except Exception as e: if transaction_active: try: self.cial.CptaApplication.RollbackTrans() except Exception: pass logger.error(f"Erreur création : {e}", exc_info=True) raise RuntimeError(f"Erreur création : {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: """Modifie un article existant dans Sage 100 - Version fusionnée""" if not self.cial: raise RuntimeError("Connexion Sage non établie") try: valide, erreur = valider_donnees_modification(article_data) if not valide: raise ValueError(erreur) with self._com_context(), self._lock_com: logger.info(f"[ARTICLE] === MODIFICATION {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() logger.info(f"[ARTICLE] Trouvé : {reference}") champs_modifies = [] champs_echoues = [] # === Gestion famille === if "famille" in article_data and article_data["famille"]: famille_code_demande = article_data["famille"].upper().strip() logger.info(f"[FAMILLE] Changement : {famille_code_demande}") try: famille_code_exact = None 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 = _safe_strip(row.FA_CodeFamille) if row.FA_Type == 1: raise ValueError( f"Famille '{famille_code_demande}' est de type Total" ) logger.info( f" [SQL] Famille trouvée : {famille_code_exact}" ) else: raise ValueError( f"Famille '{famille_code_demande}' introuvable" ) if famille_code_exact: factory_famille = self.cial.FactoryFamille famille_obj = None 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_test = ( getattr(fam_test, "FA_CodeFamille", "") .strip() .upper() ) if code_test == famille_code_exact.upper(): famille_obj = fam_test logger.info(f" [OK] Famille à index {index}") break index += 1 except Exception: break if famille_obj: famille_obj.Read() article.Famille = famille_obj champs_modifies.append("famille") logger.info( f" [OK] Famille changée : {famille_code_exact}" ) else: raise ValueError( f"Famille '{famille_code_demande}' inaccessible via COM" ) except Exception as e: logger.error(f" [ERREUR] Famille : {e}") champs_echoues.append(f"famille: {e}") # === Traitement explicite des champs === if "designation" in article_data: try: designation = str(article_data["designation"])[:69].strip() article.AR_Design = designation champs_modifies.append("designation") logger.info(f" ✓ designation = {designation}") except Exception as e: champs_echoues.append(f"designation: {e}") if "prix_vente" in article_data: try: article.AR_PrixVen = float(article_data["prix_vente"]) champs_modifies.append("prix_vente") logger.info(f" ✓ prix_vente = {article_data['prix_vente']}") except Exception as e: champs_echoues.append(f"prix_vente: {e}") if "prix_achat" in article_data: try: article.AR_PrixAchat = float(article_data["prix_achat"]) champs_modifies.append("prix_achat") logger.info(f" ✓ prix_achat = {article_data['prix_achat']}") except Exception as e: champs_echoues.append(f"prix_achat: {e}") if "coef" in article_data: try: article.AR_Coef = float(article_data["coef"]) champs_modifies.append("coef") logger.info(f" ✓ coef = {article_data['coef']}") except Exception as e: champs_echoues.append(f"coef: {e}") if "code_ean" in article_data: try: article.AR_CodeBarre = str(article_data["code_ean"])[ :13 ].strip() champs_modifies.append("code_ean") logger.info(f" ✓ code_ean = {article_data['code_ean']}") except Exception as e: champs_echoues.append(f"code_ean: {e}") if "description" in article_data: try: article.AR_Langue1 = str(article_data["description"])[ :255 ].strip() champs_modifies.append("description") logger.info(" ✓ description définie (AR_Langue1)") except Exception as e: champs_echoues.append(f"description: {e}") if "pays" in article_data: try: article.AR_Pays = str(article_data["pays"])[:3].upper() champs_modifies.append("pays") logger.info(f" ✓ pays = {article_data['pays']}") except Exception as e: champs_echoues.append(f"pays: {e}") if "garantie" in article_data: try: article.AR_Garantie = int(article_data["garantie"]) champs_modifies.append("garantie") logger.info(f" ✓ garantie = {article_data['garantie']}") except Exception as e: champs_echoues.append(f"garantie: {e}") if "delai" in article_data: try: article.AR_Delai = int(article_data["delai"]) champs_modifies.append("delai") logger.info(f" ✓ delai = {article_data['delai']}") except Exception as e: champs_echoues.append(f"delai: {e}") if "poids_net" in article_data: try: article.AR_PoidsNet = float(article_data["poids_net"]) champs_modifies.append("poids_net") logger.info(f" ✓ poids_net = {article_data['poids_net']}") except Exception as e: champs_echoues.append(f"poids_net: {e}") if "poids_brut" in article_data: try: article.AR_PoidsBrut = float(article_data["poids_brut"]) champs_modifies.append("poids_brut") logger.info(f" ✓ poids_brut = {article_data['poids_brut']}") except Exception as e: champs_echoues.append(f"poids_brut: {e}") if "code_fiscal" in article_data: try: article.AR_CodeFiscal = str(article_data["code_fiscal"])[:10] champs_modifies.append("code_fiscal") logger.info(f" ✓ code_fiscal = {article_data['code_fiscal']}") except Exception as e: champs_echoues.append(f"code_fiscal: {e}") if "soumis_escompte" in article_data: try: article.AR_Escompte = ( 1 if article_data["soumis_escompte"] else 0 ) champs_modifies.append("soumis_escompte") logger.info( f" ✓ soumis_escompte = {article_data['soumis_escompte']}" ) except Exception as e: champs_echoues.append(f"soumis_escompte: {e}") if "publie" in article_data: try: article.AR_Publie = 1 if article_data["publie"] else 0 champs_modifies.append("publie") logger.info(f" ✓ publie = {article_data['publie']}") except Exception as e: champs_echoues.append(f"publie: {e}") if "en_sommeil" in article_data: try: article.AR_Sommeil = 1 if article_data["en_sommeil"] else 0 champs_modifies.append("en_sommeil") logger.info(f" ✓ en_sommeil = {article_data['en_sommeil']}") except Exception as e: champs_echoues.append(f"en_sommeil: {e}") if champs_echoues: logger.warning( f"[WARN] Champs échoués : {', '.join(champs_echoues)}" ) if not champs_modifies: logger.warning("[ARTICLE] Aucun champ modifié") return _extraire_article(article) logger.info(f"[ARTICLE] Champs modifiés : {', '.join(champs_modifies)}") logger.info("[ARTICLE] Écriture...") # === Écriture COM === 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 Exception: pass logger.error(f"[ARTICLE] Erreur Write() : {error_detail}") raise RuntimeError(f"Échec modification : {error_detail}") # === Statistiques (AR_Stat après Write) === stats_a_modifier = [] for i in range(1, 6): stat_key = f"stat_0{i}" if stat_key in article_data: stats_a_modifier.append( ( i - 1, str(article_data[stat_key])[:20] if article_data[stat_key] else "", ) ) if stats_a_modifier: logger.info("[STATS] Modification statistiques via AR_Stat()...") try: for index, value in stats_a_modifier: article.AR_Stat(index, value) logger.info(f" ✓ stat_0{index + 1} = {value}") champs_modifies.append(f"stat_0{index + 1}") article.Write() logger.info(" [OK] Statistiques sauvegardées") except Exception as e: logger.warning(f" ⚠ Statistiques : {e}") # === Gestion stocks mini/maxi via SQL === if "stock_mini" in article_data or "stock_maxi" in article_data: try: with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT TOP 1 DE_No FROM F_DEPOT ORDER BY DE_No" ) row = cursor.fetchone() if row: depot_no = row.DE_No update_parts = [] params = [] if "stock_mini" in article_data: update_parts.append("AS_QteMini = ?") params.append(float(article_data["stock_mini"])) champs_modifies.append("stock_mini") if "stock_maxi" in article_data: update_parts.append("AS_QteMaxi = ?") params.append(float(article_data["stock_maxi"])) champs_modifies.append("stock_maxi") if update_parts: params.extend([reference.upper(), depot_no]) cursor.execute( f""" UPDATE F_ARTSTOCK SET {", ".join(update_parts)} WHERE AR_Ref = ? AND DE_No = ? """, params, ) conn.commit() logger.info(" [SQL] Stocks mini/maxi mis à jour") except Exception as e: logger.error(f"[STOCK] Erreur SQL : {e}") champs_echoues.append(f"stocks: {e}") # === Construction réponse depuis SQL === logger.info("[RESPONSE] Construction réponse depuis SQL...") try: with self._get_sql_connection() as conn: cursor = conn.cursor() # Lecture complète article cursor.execute( """ SELECT a.AR_Ref, a.AR_Design, a.AR_PrixVen, a.AR_PrixAch, a.AR_Coef, a.AR_CodeBarre, a.AR_CodeFiscal, a.AR_Pays, a.AR_Garantie, a.AR_Delai, a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_Escompte, a.AR_Publie, a.AR_Sommeil, a.FA_CodeFamille, f.FA_Intitule, a.AR_Type, a.AR_UniteVen, a.AR_Langue1, a.AR_Stat01, a.AR_Stat02, a.AR_Stat03, a.AR_Stat04, a.AR_Stat05 FROM F_ARTICLE a LEFT JOIN F_FAMILLE f ON a.FA_CodeFamille = f.FA_CodeFamille WHERE a.AR_Ref = ? """, (reference.upper(),), ) row = cursor.fetchone() if row: resultat = { "reference": _safe_strip(row[0]), "designation": _safe_strip(row[1]), "prix_vente": float(row[2]) if row[2] else 0.0, "prix_achat": float(row[3]) if row[3] else 0.0, "coef": float(row[4]) if row[4] else None, "code_ean": _safe_strip(row[5]), "code_barre": _safe_strip(row[5]), "code_fiscal": _safe_strip(row[6]), "pays": _safe_strip(row[7]), "garantie": int(row[8]) if row[8] else None, "delai": int(row[9]) if row[9] else None, "poids_net": float(row[10]) if row[10] else None, "poids_brut": float(row[11]) if row[11] else None, "soumis_escompte": bool(row[12]) if row[12] is not None else None, "publie": bool(row[13]) if row[13] is not None else None, "en_sommeil": bool(row[14]) if row[14] else False, "est_actif": not bool(row[14]) if row[14] is not None else True, "famille_code": _safe_strip(row[15]), "famille_libelle": _safe_strip(row[16]) if row[16] else "", "type_article": int(row[17]) if row[17] is not None else 0, "type_article_libelle": "Article" if not row[17] else None, "unite_vente": _safe_strip(row[18]) if row[18] else None, "description": _safe_strip(row[19]) if row[19] else None, "stat_01": _safe_strip(row[20]) if row[20] else None, "stat_02": _safe_strip(row[21]) if row[21] else None, "stat_03": _safe_strip(row[22]) if row[22] else None, "stat_04": _safe_strip(row[23]) if row[23] else None, "stat_05": _safe_strip(row[24]) if row[24] else None, } # Lecture stocks cursor.execute( """ SELECT s.AS_QteSto, s.AS_QteMini, s.AS_QteMaxi, s.AS_QteRes, s.AS_QteCom FROM F_ARTSTOCK s WHERE s.AR_Ref = ? """, (reference.upper(),), ) stock_total = 0.0 stock_mini_val = 0.0 stock_maxi_val = 0.0 stock_reserve_val = 0.0 stock_commande_val = 0.0 stock_row = cursor.fetchone() if stock_row: stock_total = ( float(stock_row[0]) if stock_row[0] else 0.0 ) stock_mini_val = ( float(stock_row[1]) if stock_row[1] else 0.0 ) stock_maxi_val = ( float(stock_row[2]) if stock_row[2] else 0.0 ) stock_reserve_val = ( float(stock_row[3]) if stock_row[3] else 0.0 ) stock_commande_val = ( float(stock_row[4]) if stock_row[4] else 0.0 ) resultat["stock_reel"] = stock_total resultat["stock_mini"] = stock_mini_val resultat["stock_maxi"] = stock_maxi_val resultat["stock_disponible"] = ( stock_total - stock_reserve_val ) resultat["stock_reserve"] = stock_reserve_val resultat["stock_commande"] = stock_commande_val logger.info( f"[ARTICLE] MODIFIÉ : {reference} ({len(champs_modifies)} champs)" ) return resultat except Exception as e: logger.warning(f"[RESPONSE] Erreur lecture SQL : {e}") # Fallback sur extraction COM article.Read() logger.info( f"[ARTICLE] MODIFIÉ : {reference} ({len(champs_modifies)} champs)" ) resultat = _extraire_article(article) if not resultat: resultat = {"reference": reference} 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 Exception: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def creer_famille(self, famille_data: dict) -> dict: with self._com_context(), self._lock_com: try: logger.info("[FAMILLE] === CRÉATION FAMILLE (TYPE DÉTAIL) ===") 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}") 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" ) factory_famille = self.cial.FactoryFamille try: 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 except Exception: index += 1 except ValueError: raise persist = factory_famille.Create() famille = win32com.client.CastTo(persist, "IBOFamille3") famille.SetDefault() famille.FA_CodeFamille = code famille.FA_Intitule = intitule try: famille.FA_Type = 0 logger.info("[FAMILLE] Type : 0 (Détail)") except Exception as e: logger.warning(f"[FAMILLE] FA_Type non défini : {e}") 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}") 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 Exception: pass logger.error(f"[FAMILLE] Erreur Write() : {error_detail}") raise RuntimeError(f"Échec création famille : {error_detail}") famille.Read() resultat = { "code": getattr(famille, "FA_CodeFamille", "").strip(), "intitule": getattr(famille, "FA_Intitule", "").strip(), "type": 0, "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 Exception: pass raise RuntimeError(f"Erreur technique Sage : {error_message}") def lister_toutes_familles( self, filtre: str = "", inclure_totaux: bool = True ) -> List[Dict]: """Liste toutes les familles avec leurs comptes comptables et fournisseur principal""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() logger.info( "[SQL] Chargement des familles avec F_FAMCOMPTA et F_FAMFOURNISS..." ) query = """ SELECT -- F_FAMILLE - Identification f.FA_CodeFamille, f.FA_Type, f.FA_Intitule, f.FA_UniteVen, f.FA_Coef, f.FA_SuiviStock, f.FA_Garantie, f.FA_Central, -- F_FAMILLE - Statistiques f.FA_Stat01, f.FA_Stat02, f.FA_Stat03, f.FA_Stat04, f.FA_Stat05, -- F_FAMILLE - Fiscal et gestion f.FA_CodeFiscal, f.FA_Pays, f.FA_UnitePoids, f.FA_Escompte, f.FA_Delai, f.FA_HorsStat, f.FA_VteDebit, f.FA_NotImp, -- F_FAMILLE - Frais annexes (3 frais avec 3 remises chacun) f.FA_Frais01FR_Denomination, f.FA_Frais01FR_Rem01REM_Valeur, f.FA_Frais01FR_Rem01REM_Type, f.FA_Frais01FR_Rem02REM_Valeur, f.FA_Frais01FR_Rem02REM_Type, f.FA_Frais01FR_Rem03REM_Valeur, f.FA_Frais01FR_Rem03REM_Type, f.FA_Frais02FR_Denomination, f.FA_Frais02FR_Rem01REM_Valeur, f.FA_Frais02FR_Rem01REM_Type, f.FA_Frais02FR_Rem02REM_Valeur, f.FA_Frais02FR_Rem02REM_Type, f.FA_Frais02FR_Rem03REM_Valeur, f.FA_Frais02FR_Rem03REM_Type, f.FA_Frais03FR_Denomination, f.FA_Frais03FR_Rem01REM_Valeur, f.FA_Frais03FR_Rem01REM_Type, f.FA_Frais03FR_Rem02REM_Valeur, f.FA_Frais03FR_Rem02REM_Type, f.FA_Frais03FR_Rem03REM_Valeur, f.FA_Frais03FR_Rem03REM_Type, -- F_FAMILLE - Options diverses f.FA_Contremarque, f.FA_FactPoids, f.FA_FactForfait, f.FA_Publie, f.FA_RacineRef, f.FA_RacineCB, -- F_FAMILLE - Catégories f.CL_No1, f.CL_No2, f.CL_No3, f.CL_No4, -- F_FAMILLE - Gestion avancée f.FA_Nature, f.FA_NbColis, f.FA_SousTraitance, f.FA_Fictif, f.FA_Criticite, -- F_FAMILLE - Métadonnées système f.cbMarq, f.cbCreateur, f.cbModification, f.cbCreation, f.cbCreationUser, -- F_FAMCOMPTA Vente (FCP_Type = 0) vte.FCP_ComptaCPT_CompteG, vte.FCP_ComptaCPT_CompteA, vte.FCP_ComptaCPT_Taxe1, vte.FCP_ComptaCPT_Taxe2, vte.FCP_ComptaCPT_Taxe3, vte.FCP_ComptaCPT_Date1, vte.FCP_ComptaCPT_Date2, vte.FCP_ComptaCPT_Date3, vte.FCP_TypeFacture, -- F_FAMCOMPTA Achat (FCP_Type = 1) ach.FCP_ComptaCPT_CompteG, ach.FCP_ComptaCPT_CompteA, ach.FCP_ComptaCPT_Taxe1, ach.FCP_ComptaCPT_Taxe2, ach.FCP_ComptaCPT_Taxe3, ach.FCP_ComptaCPT_Date1, ach.FCP_ComptaCPT_Date2, ach.FCP_ComptaCPT_Date3, ach.FCP_TypeFacture, -- F_FAMCOMPTA Stock (FCP_Type = 2) sto.FCP_ComptaCPT_CompteG, sto.FCP_ComptaCPT_CompteA, -- F_FAMFOURNISS (fournisseur principal FF_Principal=1) ff.CT_Num, ff.FF_Unite, ff.FF_Conversion, ff.FF_DelaiAppro, ff.FF_Garantie, ff.FF_Colisage, ff.FF_QteMini, ff.FF_QteMont, ff.EG_Champ, ff.FF_Devise, ff.FF_Remise, ff.FF_ConvDiv, ff.FF_TypeRem, -- Nombre d'articles ISNULL(COUNT(DISTINCT a.AR_Ref), 0) as nb_articles FROM F_FAMILLE f -- Jointures comptables LEFT JOIN F_FAMCOMPTA vte ON f.FA_CodeFamille = vte.FA_CodeFamille AND vte.FCP_Type = 0 -- Vente AND vte.FCP_Champ = 1 -- Compte principal LEFT JOIN F_FAMCOMPTA ach ON f.FA_CodeFamille = ach.FA_CodeFamille AND ach.FCP_Type = 1 -- Achat AND ach.FCP_Champ = 1 LEFT JOIN F_FAMCOMPTA sto ON f.FA_CodeFamille = sto.FA_CodeFamille AND sto.FCP_Type = 2 -- Stock AND sto.FCP_Champ = 1 -- Fournisseur principal LEFT JOIN F_FAMFOURNISS ff ON f.FA_CodeFamille = ff.FA_CodeFamille AND ff.FF_Principal = 1 -- Nombre d'articles LEFT JOIN F_ARTICLE a ON f.FA_CodeFamille = a.FA_CodeFamille WHERE 1=1 """ params = [] if not inclure_totaux: query += " AND f.FA_Type = 0" logger.info("[SQL] Filtre : FA_Type = 0 (Détail uniquement)") if filtre: query += """ AND ( f.FA_CodeFamille LIKE ? OR f.FA_Intitule LIKE ? ) """ params.extend([f"%{filtre}%", f"%{filtre}%"]) query += """ GROUP BY f.FA_CodeFamille, f.FA_Type, f.FA_Intitule, f.FA_UniteVen, f.FA_Coef, f.FA_SuiviStock, f.FA_Garantie, f.FA_Central, f.FA_Stat01, f.FA_Stat02, f.FA_Stat03, f.FA_Stat04, f.FA_Stat05, f.FA_CodeFiscal, f.FA_Pays, f.FA_UnitePoids, f.FA_Escompte, f.FA_Delai, f.FA_HorsStat, f.FA_VteDebit, f.FA_NotImp, f.FA_Frais01FR_Denomination, f.FA_Frais01FR_Rem01REM_Valeur, f.FA_Frais01FR_Rem01REM_Type, f.FA_Frais01FR_Rem02REM_Valeur, f.FA_Frais01FR_Rem02REM_Type, f.FA_Frais01FR_Rem03REM_Valeur, f.FA_Frais01FR_Rem03REM_Type, f.FA_Frais02FR_Denomination, f.FA_Frais02FR_Rem01REM_Valeur, f.FA_Frais02FR_Rem01REM_Type, f.FA_Frais02FR_Rem02REM_Valeur, f.FA_Frais02FR_Rem02REM_Type, f.FA_Frais02FR_Rem03REM_Valeur, f.FA_Frais02FR_Rem03REM_Type, f.FA_Frais03FR_Denomination, f.FA_Frais03FR_Rem01REM_Valeur, f.FA_Frais03FR_Rem01REM_Type, f.FA_Frais03FR_Rem02REM_Valeur, f.FA_Frais03FR_Rem02REM_Type, f.FA_Frais03FR_Rem03REM_Valeur, f.FA_Frais03FR_Rem03REM_Type, f.FA_Contremarque, f.FA_FactPoids, f.FA_FactForfait, f.FA_Publie, f.FA_RacineRef, f.FA_RacineCB, f.CL_No1, f.CL_No2, f.CL_No3, f.CL_No4, f.FA_Nature, f.FA_NbColis, f.FA_SousTraitance, f.FA_Fictif, f.FA_Criticite, f.cbMarq, f.cbCreateur, f.cbModification, f.cbCreation, f.cbCreationUser, vte.FCP_ComptaCPT_CompteG, vte.FCP_ComptaCPT_CompteA, vte.FCP_ComptaCPT_Taxe1, vte.FCP_ComptaCPT_Taxe2, vte.FCP_ComptaCPT_Taxe3, vte.FCP_ComptaCPT_Date1, vte.FCP_ComptaCPT_Date2, vte.FCP_ComptaCPT_Date3, vte.FCP_TypeFacture, ach.FCP_ComptaCPT_CompteG, ach.FCP_ComptaCPT_CompteA, ach.FCP_ComptaCPT_Taxe1, ach.FCP_ComptaCPT_Taxe2, ach.FCP_ComptaCPT_Taxe3, ach.FCP_ComptaCPT_Date1, ach.FCP_ComptaCPT_Date2, ach.FCP_ComptaCPT_Date3, ach.FCP_TypeFacture, sto.FCP_ComptaCPT_CompteG, sto.FCP_ComptaCPT_CompteA, ff.CT_Num, ff.FF_Unite, ff.FF_Conversion, ff.FF_DelaiAppro, ff.FF_Garantie, ff.FF_Colisage, ff.FF_QteMini, ff.FF_QteMont, ff.EG_Champ, ff.FF_Devise, ff.FF_Remise, ff.FF_ConvDiv, ff.FF_TypeRem ORDER BY f.FA_Intitule """ cursor.execute(query, params) rows = cursor.fetchall() def to_str(val): """Convertit en string, gère None et int""" if val is None: return "" return str(val).strip() if isinstance(val, str) else str(val) def to_float(val): """Convertit en float, gère None""" if val is None or val == "": return 0.0 try: return float(val) except (ValueError, TypeError): return 0.0 def to_int(val): """Convertit en int, gère None""" if val is None or val == "": return 0 try: return int(val) except (ValueError, TypeError): return 0 def to_bool(val): """Convertit en bool""" if val is None: return False if isinstance(val, bool): return val if isinstance(val, int): return val != 0 return bool(val) familles = [] for row in rows: idx = 0 famille = { "code": to_str(row[idx]), "type": to_int(row[idx + 1]), "intitule": to_str(row[idx + 2]), "unite_vente": to_str(row[idx + 3]), "coef": to_float(row[idx + 4]), "suivi_stock": to_bool(row[idx + 5]), "garantie": to_int(row[idx + 6]), "est_centrale": to_bool(row[idx + 7]), } idx += 8 famille.update( { "stat_01": to_str(row[idx]), "stat_02": to_str(row[idx + 1]), "stat_03": to_str(row[idx + 2]), "stat_04": to_str(row[idx + 3]), "stat_05": to_str(row[idx + 4]), } ) idx += 5 famille.update( { "code_fiscal": to_str(row[idx]), "pays": to_str(row[idx + 1]), "unite_poids": to_str(row[idx + 2]), "escompte": to_bool(row[idx + 3]), "delai": to_int(row[idx + 4]), "hors_statistique": to_bool(row[idx + 5]), "vente_debit": to_bool(row[idx + 6]), "non_imprimable": to_bool(row[idx + 7]), } ) idx += 8 famille.update( { "frais_01_libelle": to_str(row[idx]), "frais_01_remise_1_valeur": to_float(row[idx + 1]), "frais_01_remise_1_type": to_int(row[idx + 2]), "frais_01_remise_2_valeur": to_float(row[idx + 3]), "frais_01_remise_2_type": to_int(row[idx + 4]), "frais_01_remise_3_valeur": to_float(row[idx + 5]), "frais_01_remise_3_type": to_int(row[idx + 6]), "frais_02_libelle": to_str(row[idx + 7]), "frais_02_remise_1_valeur": to_float(row[idx + 8]), "frais_02_remise_1_type": to_int(row[idx + 9]), "frais_02_remise_2_valeur": to_float(row[idx + 10]), "frais_02_remise_2_type": to_int(row[idx + 11]), "frais_02_remise_3_valeur": to_float(row[idx + 12]), "frais_02_remise_3_type": to_int(row[idx + 13]), "frais_03_libelle": to_str(row[idx + 14]), "frais_03_remise_1_valeur": to_float(row[idx + 15]), "frais_03_remise_1_type": to_int(row[idx + 16]), "frais_03_remise_2_valeur": to_float(row[idx + 17]), "frais_03_remise_2_type": to_int(row[idx + 18]), "frais_03_remise_3_valeur": to_float(row[idx + 19]), "frais_03_remise_3_type": to_int(row[idx + 20]), } ) idx += 21 famille.update( { "contremarque": to_bool(row[idx]), "fact_poids": to_bool(row[idx + 1]), "fact_forfait": to_bool(row[idx + 2]), "publie": to_bool(row[idx + 3]), "racine_reference": to_str(row[idx + 4]), "racine_code_barre": to_str(row[idx + 5]), } ) idx += 6 famille.update( { "categorie_1": to_int(row[idx]), "categorie_2": to_int(row[idx + 1]), "categorie_3": to_int(row[idx + 2]), "categorie_4": to_int(row[idx + 3]), } ) idx += 4 famille.update( { "nature": to_int(row[idx]), "nb_colis": to_int(row[idx + 1]), "sous_traitance": to_bool(row[idx + 2]), "fictif": to_bool(row[idx + 3]), "criticite": to_int(row[idx + 4]), } ) idx += 5 famille.update( { "cb_marq": to_int(row[idx]), "cb_createur": to_str(row[idx + 1]), "cb_modification": row[idx + 2], "cb_creation": row[idx + 3], "cb_creation_user": to_str(row[idx + 4]), } ) idx += 5 famille.update( { "compte_vente": to_str(row[idx]), "compte_auxiliaire_vente": to_str(row[idx + 1]), "tva_vente_1": to_str(row[idx + 2]), "tva_vente_2": to_str(row[idx + 3]), "tva_vente_3": to_str(row[idx + 4]), "tva_vente_date_1": row[idx + 5], "tva_vente_date_2": row[idx + 6], "tva_vente_date_3": row[idx + 7], "type_facture_vente": to_int(row[idx + 8]), } ) idx += 9 famille.update( { "compte_achat": to_str(row[idx]), "compte_auxiliaire_achat": to_str(row[idx + 1]), "tva_achat_1": to_str(row[idx + 2]), "tva_achat_2": to_str(row[idx + 3]), "tva_achat_3": to_str(row[idx + 4]), "tva_achat_date_1": row[idx + 5], "tva_achat_date_2": row[idx + 6], "tva_achat_date_3": row[idx + 7], "type_facture_achat": to_int(row[idx + 8]), } ) idx += 9 famille.update( { "compte_stock": to_str(row[idx]), "compte_auxiliaire_stock": to_str(row[idx + 1]), } ) idx += 2 famille.update( { "fournisseur_principal": to_str(row[idx]), "fournisseur_unite": to_str(row[idx + 1]), "fournisseur_conversion": to_float(row[idx + 2]), "fournisseur_delai_appro": to_int(row[idx + 3]), "fournisseur_garantie": to_int(row[idx + 4]), "fournisseur_colisage": to_int(row[idx + 5]), "fournisseur_qte_mini": to_float(row[idx + 6]), "fournisseur_qte_mont": to_float(row[idx + 7]), "fournisseur_enumere_gamme": to_int(row[idx + 8]), "fournisseur_devise": to_int(row[idx + 9]), "fournisseur_remise": to_float(row[idx + 10]), "fournisseur_conv_div": to_float(row[idx + 11]), "fournisseur_type_remise": to_int(row[idx + 12]), } ) idx += 13 famille["nb_articles"] = to_int(row[idx]) famille["type_libelle"] = ( "Total" if famille["type"] == 1 else "Détail" ) famille["est_total"] = famille["type"] == 1 famille["est_detail"] = famille["type"] == 0 famille["FA_CodeFamille"] = famille["code"] famille["FA_Intitule"] = famille["intitule"] famille["FA_Type"] = famille["type"] famille["CG_NumVte"] = famille["compte_vente"] famille["CG_NumAch"] = famille["compte_achat"] familles.append(famille) type_msg = "DÉTAIL uniquement" if not inclure_totaux else "TOUS types" logger.info(f" {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._get_sql_connection() as conn: cursor = conn.cursor() logger.info(f"[SQL] Lecture famille : {code}") 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_SuiviStock", "FA_Garantie", "FA_UnitePoids", "FA_Delai", "FA_NbColis", "CG_NumAch", "CG_NumVte", "FA_CodeFiscal", "FA_Escompte", "FA_Central", "FA_Nature", "CL_No1", "CL_No2", "CL_No3", "CL_No4", "FA_Stat01", "FA_Stat02", "FA_Stat03", "FA_Stat04", "FA_Stat05", "FA_HorsStat", "FA_Pays", "FA_VteDebit", "FA_NotImp", "FA_Contremarque", "FA_FactPoids", "FA_FactForfait", "FA_Publie", "FA_RacineRef", "FA_RacineCB", "FA_Raccourci", "FA_SousTraitance", "FA_Fictif", "FA_Criticite", ] 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([f"f.{col}" for col in colonnes_a_lire]) query = f""" SELECT {colonnes_str}, ISNULL(COUNT(a.AR_Ref), 0) as nb_articles FROM F_FAMILLE f LEFT JOIN F_ARTICLE a ON f.FA_CodeFamille = a.FA_CodeFamille WHERE UPPER(f.FA_CodeFamille) = ? GROUP BY {colonnes_str} """ cursor.execute(query, (code.upper().strip(),)) row = cursor.fetchone() if not row: raise ValueError(f"Famille '{code}' introuvable dans Sage") famille = {} for idx, colonne in enumerate(colonnes_a_lire): valeur = row[idx] if isinstance(valeur, str): valeur = valeur.strip() famille[colonne] = valeur famille["nb_articles"] = row[-1] if "FA_CodeFamille" in famille: famille["code"] = famille["FA_CodeFamille"] if "FA_Intitule" in famille: famille["intitule"] = famille["FA_Intitule"] 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 famille["unite_vente"] = str(famille.get("FA_UniteVen", "")) famille["unite_poids"] = str(famille.get("FA_UnitePoids", "")) famille["coef"] = ( float(famille.get("FA_Coef", 0.0)) if famille.get("FA_Coef") is not None else 0.0 ) famille["suivi_stock"] = bool(famille.get("FA_SuiviStock", 0)) famille["garantie"] = int(famille.get("FA_Garantie", 0)) famille["delai"] = int(famille.get("FA_Delai", 0)) famille["nb_colis"] = int(famille.get("FA_NbColis", 0)) famille["compte_achat"] = famille.get("CG_NumAch", "") famille["compte_vente"] = famille.get("CG_NumVte", "") famille["code_fiscal"] = famille.get("FA_CodeFiscal", "") famille["escompte"] = bool(famille.get("FA_Escompte", 0)) famille["est_centrale"] = bool(famille.get("FA_Central", 0)) famille["nature"] = famille.get("FA_Nature", 0) famille["pays"] = famille.get("FA_Pays", "") famille["categorie_1"] = famille.get("CL_No1", 0) famille["categorie_2"] = famille.get("CL_No2", 0) famille["categorie_3"] = famille.get("CL_No3", 0) famille["categorie_4"] = famille.get("CL_No4", 0) famille["stat_01"] = famille.get("FA_Stat01", "") famille["stat_02"] = famille.get("FA_Stat02", "") famille["stat_03"] = famille.get("FA_Stat03", "") famille["stat_04"] = famille.get("FA_Stat04", "") famille["stat_05"] = famille.get("FA_Stat05", "") famille["hors_statistique"] = bool(famille.get("FA_HorsStat", 0)) famille["vente_debit"] = bool(famille.get("FA_VteDebit", 0)) famille["non_imprimable"] = bool(famille.get("FA_NotImp", 0)) famille["contremarque"] = bool(famille.get("FA_Contremarque", 0)) famille["fact_poids"] = bool(famille.get("FA_FactPoids", 0)) famille["fact_forfait"] = bool(famille.get("FA_FactForfait", 0)) famille["publie"] = bool(famille.get("FA_Publie", 0)) famille["racine_reference"] = famille.get("FA_RacineRef", "") famille["racine_code_barre"] = famille.get("FA_RacineCB", "") famille["raccourci"] = famille.get("FA_Raccourci", "") famille["sous_traitance"] = bool(famille.get("FA_SousTraitance", 0)) famille["fictif"] = bool(famille.get("FA_Fictif", 0)) famille["criticite"] = int(famille.get("FA_Criticite", 0)) logger.info( f"SQL: Famille '{famille['code']}' chargée ({famille['nb_articles']} articles)" ) return famille except ValueError as e: logger.error(f"Erreur famille: {e}") raise except Exception as e: logger.error(f"Erreur SQL famille: {e}", exc_info=True) raise RuntimeError(f"Erreur lecture famille: {str(e)}") def creer_entree_stock(self, entree_data: Dict) -> Dict: try: with self._com_context(), self._lock_com: logger.info("[STOCK] === CRÉATION ENTRÉE STOCK (COM pur) ===") transaction_active = False try: self.cial.CptaApplication.BeginTrans() transaction_active = True logger.debug("Transaction démarrée") except Exception: pass try: factory_doc = self.cial.FactoryDocumentStock persist_doc = factory_doc.CreateType(180) doc = win32com.client.CastTo(persist_doc, "IBODocumentStock3") doc.SetDefault() 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("[STOCK] Document créé") factory_article = self.cial.FactoryArticle factory_depot = self.cial.FactoryDepot stocks_mis_a_jour = [] depot_principal = None 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}") try: factory_lignes = doc.FactoryDocumentLigne except Exception: 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}") 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() ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except Exception: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentStockLigne3" ) ligne_obj.SetDefault() try: ligne_obj.SetDefaultArticleReference( article_ref, float(quantite) ) except Exception: try: ligne_obj.SetDefaultArticle( article_obj, float(quantite) ) except Exception: raise ValueError( f"Impossible de lier l'article {article_ref}" ) prix = ligne_data.get("prix_unitaire") if prix: try: ligne_obj.DL_PrixUnitaire = float(prix) except Exception: pass ligne_obj.Write() if stock_mini is not None or stock_maxi is not None: logger.info( f"[STOCK] Ajustement stock pour {article_ref}..." ) try: logger.info( " [COM] Méthode A : Article.FactoryArticleStock" ) 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() 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: 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() 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}" ) 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 Exception: pass index_stock += 1 except Exception as e: logger.debug( f" Erreur stock {index_stock}: {e}" ) index_stock += 1 if not stock_trouve: try: stock_persist = ( factory_article_stock.Create() ) stock_trouve = win32com.client.CastTo( stock_persist, "IBOArticleStock3" ) stock_trouve.SetDefault() if depot_principal: try: stock_trouve.Depot = depot_principal logger.info( " Dépôt principal lié" ) except Exception: pass logger.info(" Nouvel ArticleStock créé") except Exception as e: logger.error( f" Impossible de créer ArticleStock: {e}" ) raise if stock_trouve: try: stock_trouve.Read() except Exception: pass if stock_mini is not None: try: 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}" ) if stock_maxi is not None: try: 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}" ) try: stock_trouve.Write() logger.info(" ArticleStock sauvegardé") except Exception as e: logger.error( f" Erreur Write() ArticleStock: {e}" ) raise if depot_principal and ( stock_mini is not None or stock_maxi is not None ): logger.info( " [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 Exception: continue if factory_depot_stock: 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 Exception: index_ds += 1 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}" ) 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, ) stocks_mis_a_jour.append( { "article_ref": article_ref, "quantite_ajoutee": quantite, "stock_mini_defini": stock_mini, "stock_maxi_defini": stock_maxi, } ) doc.Write() doc.Read() numero = getattr(doc, "DO_Piece", "") logger.info(f"[STOCK] Document finalisé: {numero}") logger.info("[STOCK] Vérification finale via COM...") for stock_info in stocks_mis_a_jour: article_ref = stock_info["article_ref"] try: persist_article = factory_article.ReadReference(article_ref) article_verif = win32com.client.CastTo( persist_article, "IBOArticle3" ) article_verif.Read() stock_total = 0.0 stock_mini_lu = 0.0 stock_maxi_lu = 0.0 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 Exception: 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 Exception: 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 Exception: 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}" ) if transaction_active: try: self.cial.CptaApplication.CommitTrans() logger.info("[STOCK] Transaction committée") except Exception: logger.info("[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("[STOCK] Transaction annulée") except Exception: 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 creer_sortie_stock(self, sortie_data: Dict) -> Dict: try: with ( self._com_context(), self._lock_com, self._get_sql_connection() as conn, ): cursor = conn.cursor() logger.info("[STOCK] === CRÉATION SORTIE STOCK ===") logger.info(f"[STOCK] {len(sortie_data.get('lignes', []))} ligne(s)") try: self.cial.CptaApplication.BeginTrans() except Exception: pass try: factory = self.cial.FactoryDocumentStock persist = factory.CreateType(181) doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.SetDefault() 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()) if sortie_data.get("reference"): doc.DO_Ref = sortie_data["reference"] doc.Write() logger.info( f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}" ) try: factory_lignes = doc.FactoryDocumentLigne except Exception: factory_lignes = doc.FactoryDocumentStockLigne factory_article = self.cial.FactoryArticle stocks_mis_a_jour = [] 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} ========" ) 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}") stock_dispo = verifier_stock_suffisant( article_ref, quantite, cursor, 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']}" ) numero_lot = ligne_data.get("numero_lot") if ar_suivi == 1: if numero_lot: logger.warning("[STOCK] CMUP : Suppression du lot") numero_lot = None elif ar_suivi == 2: 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}'" ) ligne_persist = factory_lignes.Create() try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) except Exception: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentStockLigne3" ) ligne_obj.SetDefault() article_lie = False try: ligne_obj.SetDefaultArticleReference( article_ref, float(quantite) ) article_lie = True logger.info("[STOCK] SetDefaultArticleReference()") except Exception: try: ligne_obj.SetDefaultArticle( article_obj, float(quantite) ) article_lie = True logger.info("[STOCK] SetDefaultArticle()") except Exception: pass if not article_lie: raise ValueError( f"Impossible de lier l'article {article_ref}" ) if numero_lot and ar_suivi == 2: try: ligne_obj.SetDefaultLot(numero_lot) logger.info("[STOCK] Lot défini") except Exception: try: ligne_obj.LS_NoSerie = numero_lot logger.info("[STOCK] Lot via LS_NoSerie") except Exception: pass prix = ligne_data.get("prix_unitaire") if prix: try: ligne_obj.DL_PrixUnitaire = float(prix) except Exception: pass ligne_obj.Write() logger.info("[STOCK] Write() réussi") ligne_obj.Read() ref_verifiee = article_ref 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 Exception: 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, } ) doc.Write() doc.Read() numero = getattr(doc, "DO_Piece", "") logger.info(f"[STOCK] Document finalisé : {numero}") try: self.cial.CptaApplication.CommitTrans() logger.info("[STOCK] Transaction committée") except Exception: 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 Exception: 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 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 Exception: index += 1 if not persist: raise ValueError(f"Mouvement {numero} introuvable") doc = win32com.client.CastTo(persist, "IBODocumentStock3") doc.Read() 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": [], } 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 Exception: ligne = win32com.client.CastTo( ligne_p, "IBODocumentStockLigne3" ) ligne.Read() article_ref = "" try: article_obj = getattr(ligne, "Article", None) if article_obj: article_obj.Read() article_ref = getattr( article_obj, "AR_Ref", "" ).strip() except Exception: 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 Exception: 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 lister_tous_tiers( self, type_tiers: Optional[str] = None, filtre: str = "" ) -> List[Dict]: """Liste tous les tiers avec jointure sur le collaborateur/commercial""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = _build_tiers_select_query() query += """ FROM F_COMPTET t LEFT JOIN F_COLLABORATEUR c ON t.CO_No = c.CO_No WHERE 1=1 """ params = [] if type_tiers and type_tiers != "all": if type_tiers == "prospect": query += " AND t.CT_Type = 0 AND t.CT_Prospect = 1" elif type_tiers == "client": query += " AND t.CT_Type = 0 AND t.CT_Prospect = 0" elif type_tiers == "fournisseur": query += " AND t.CT_Type = 1" if filtre: query += " AND (t.CT_Num LIKE ? OR t.CT_Intitule LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY t.CT_Intitule" cursor.execute(query, params) rows = cursor.fetchall() tiers_list = [] for row in rows: tiers = _row_to_tiers_dict(row) tiers["contacts"] = _get_contacts(row.CT_Num, conn) tiers_list.append(tiers) logger.info( f"✓ SQL: {len(tiers_list)} tiers retournés (type={type_tiers}, filtre={filtre})" ) return tiers_list except Exception as e: logger.error(f"✗ Erreur SQL tiers: {e}") raise RuntimeError(f"Erreur lecture tiers: {str(e)}") def lire_tiers(self, code: str) -> Optional[Dict]: """Lit un tiers (client/fournisseur/prospect) par code avec son commercial""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = _build_tiers_select_query() query += """ FROM F_COMPTET t LEFT JOIN F_COLLABORATEUR c ON t.CO_No = c.CO_No WHERE t.CT_Num = ? """ cursor.execute(query, (code.upper(),)) row = cursor.fetchone() if not row: return None tiers = _row_to_tiers_dict(row) tiers["contacts"] = _get_contacts(row.CT_Num, conn) logger.info(f"✓ SQL: Tiers {code} lu avec succès") return tiers except Exception as e: logger.error(f"✗ Erreur SQL tiers {code}: {e}") return None def lister_tous_collaborateurs(self, filtre="", actifs_seulement=True): """Liste tous les collaborateurs""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CO_No, CO_Nom, CO_Prenom, CO_Fonction, CO_Adresse, CO_Complement, CO_CodePostal, CO_Ville, CO_CodeRegion, CO_Pays, CO_Service, CO_Vendeur, CO_Caissier, CO_Acheteur, CO_Telephone, CO_Telecopie, CO_EMail, CO_TelPortable, CO_Matricule, CO_Facebook, CO_LinkedIn, CO_Skype, CO_Sommeil, CO_ChefVentes, CO_NoChefVentes FROM F_COLLABORATEUR WHERE 1=1 """ params = [] if actifs_seulement: query += " AND CO_Sommeil = 0" if filtre: query += " AND (CO_Nom LIKE ? OR CO_Prenom LIKE ?)" params.extend([f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY CO_Nom, CO_Prenom" cursor.execute(query, params) rows = cursor.fetchall() # ⚠️⚠️⚠️ VÉRIFIE CETTE LIGNE ⚠️⚠️⚠️ collaborateurs = [row_to_collaborateur_dict(row) for row in rows] logger.info(f"✓ SQL: {len(collaborateurs)} collaborateurs") return collaborateurs except Exception as e: logger.error(f"✗ Erreur SQL collaborateurs: {e}") raise RuntimeError(f"Erreur lecture collaborateurs: {str(e)}") def lire_collaborateur(self, numero): """Lit un collaborateur par son numéro""" try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = """ SELECT CO_No, CO_Nom, CO_Prenom, CO_Fonction, CO_Adresse, CO_Complement, CO_CodePostal, CO_Ville, CO_CodeRegion, CO_Pays, CO_Service, CO_Vendeur, CO_Caissier, CO_Acheteur, CO_Telephone, CO_Telecopie, CO_EMail, CO_TelPortable, CO_Matricule, CO_Facebook, CO_LinkedIn, CO_Skype, CO_Sommeil, CO_ChefVentes, CO_NoChefVentes FROM F_COLLABORATEUR WHERE CO_No = ? """ cursor.execute(query, (numero,)) row = cursor.fetchone() if not row: return None # ⚠️ UTILISER LA FONCTION DE CLASSE EXISTANTE collaborateur = row_to_collaborateur_dict(row) logger.info( f"✓ SQL: Collaborateur {numero} avec {len(collaborateur)} champs" ) return collaborateur except Exception as e: logger.error(f"✗ Erreur SQL collaborateur {numero}: {e}") return None def creer_collaborateur(self, data: dict) -> dict: """Crée un nouveau collaborateur via COM Sage (BOI)""" if not self.cial: raise RuntimeError("Connexion Sage non établie") # Validation préalable if not data.get("nom"): raise ValueError("Le champ 'nom' est obligatoire") nom_upper = str(data["nom"]).upper().strip()[:35] prenom = str(data.get("prenom", "")).strip()[:35] if data.get("prenom") else "" logger.info(f"\n{'=' * 70}") logger.info(f"📝 CRÉATION COLLABORATEUR: {nom_upper} {prenom}") logger.info(f"{'=' * 70}") try: with self._com_context(), self._lock_com: # ===== VÉRIFICATION DOUBLON VIA SQL ===== logger.info("🔍 Vérification doublon...") with self._get_sql_connection() as conn: cursor = conn.cursor() cursor.execute( "SELECT CO_No FROM F_COLLABORATEUR WHERE CO_Nom = ? AND CO_Prenom = ?", (nom_upper, prenom), ) existing = cursor.fetchone() if existing: raise ValueError( f"Le collaborateur '{nom_upper} {prenom}' existe déjà (N°{existing[0]})" ) logger.info("✓ Pas de doublon") # ===== FACTORY + CREATE ===== try: factory = self.cial.FactoryCollaborateur except AttributeError: factory = self.cial.CptaApplication.FactoryCollaborateur persist = factory.Create() # Cast vers interface collab = None for iface in [ "IBOCollaborateur3", "IBOCollaborateur2", "IBOCollaborateur", ]: try: collab = win32com.client.CastTo(persist, iface) logger.info(f"✓ Cast vers {iface}") break except Exception: pass if not collab: collab = persist # ===== SETDEFAULT ===== try: collab.SetDefault() logger.info("✓ SetDefault()") except Exception as e: logger.warning(f"SetDefault() ignoré: {e}") # ===== HELPER ===== def safe_set(obj, attr, value, max_len=None): """Affecte une valeur de manière sécurisée""" if value is None or value == "": return False try: val = str(value) if max_len: val = val[:max_len] setattr(obj, attr, val) logger.debug(f" ✓ {attr} = '{val}'") return True except Exception as e: logger.warning(f" ✗ {attr}: {e}") return False # ===== CHAMPS DIRECTS SUR COLLABORATEUR ===== logger.info("📝 Champs directs...") # Obligatoire safe_set(collab, "Nom", nom_upper, 35) # Optionnels safe_set(collab, "Prenom", prenom, 35) safe_set(collab, "Fonction", data.get("fonction"), 35) safe_set(collab, "Service", data.get("service"), 35) safe_set(collab, "Matricule", data.get("matricule"), 10) safe_set(collab, "Facebook", data.get("facebook"), 35) safe_set(collab, "LinkedIn", data.get("linkedin"), 35) safe_set(collab, "Skype", data.get("skype"), 35) # ===== SOUS-OBJET ADRESSE ===== logger.info("📍 Adresse...") try: adresse_obj = collab.Adresse safe_set(adresse_obj, "Adresse", data.get("adresse"), 35) safe_set(adresse_obj, "Complement", data.get("complement"), 35) safe_set(adresse_obj, "CodePostal", data.get("code_postal"), 9) safe_set(adresse_obj, "Ville", data.get("ville"), 35) safe_set(adresse_obj, "CodeRegion", data.get("code_region"), 25) safe_set(adresse_obj, "Pays", data.get("pays"), 35) except Exception as e: logger.warning(f"⚠️ Erreur Adresse: {e}") # ===== SOUS-OBJET TELECOM ===== logger.info("📞 Telecom...") try: telecom_obj = collab.Telecom safe_set(telecom_obj, "Telephone", data.get("telephone"), 21) safe_set(telecom_obj, "Telecopie", data.get("telecopie"), 21) safe_set(telecom_obj, "EMail", data.get("email"), 69) safe_set(telecom_obj, "Portable", data.get("tel_portable"), 21) except Exception as e: logger.warning(f"⚠️ Erreur Telecom: {e}") # ===== CHAMPS BOOLÉENS (seulement si True) ===== logger.info("🔘 Booléens...") if data.get("vendeur") is True: try: collab.Vendeur = True logger.debug(" ✓ Vendeur = True") except Exception: pass if data.get("caissier") is True: try: collab.Caissier = True except Exception: pass if data.get("acheteur") is True: try: collab.Acheteur = True except Exception: pass if data.get("sommeil") is True: try: collab.Sommeil = True except Exception: pass if data.get("chef_ventes") is True: try: collab.ChefVentes = True except Exception: pass # ===== WRITE ===== logger.info("💾 Write()...") try: collab.Write() logger.info("✅ Write() RÉUSSI!") except Exception as e: logger.error(f"❌ Write() échoué: {e}") raise RuntimeError(f"Échec Write(): {e}") # ===== RÉCUPÉRATION DU NUMÉRO ===== numero_cree = None # Via Read() try: collab.Read() for attr in ["No", "CO_No", "Numero"]: try: val = getattr(collab, attr) if val and isinstance(val, int): numero_cree = val break except Exception: pass except Exception: pass # Via SQL si pas trouvé if not numero_cree: try: with self._get_sql_connection() as conn: cursor = conn.cursor() query = f"SELECT CO_No FROM F_COLLABORATEUR WHERE CO_Nom = '{nom_upper}'" if prenom: query += f" AND CO_Prenom = '{prenom}'" cursor.execute(query) row = cursor.fetchone() if row: numero_cree = row[0] except Exception as e: logger.warning(f"SQL récup numéro: {e}") logger.info(f"\n{'=' * 70}") logger.info( f"✅ COLLABORATEUR CRÉÉ: N°{numero_cree} - {nom_upper} {prenom}" ) logger.info(f"{'=' * 70}") # Retourner le collaborateur if numero_cree: return self.lire_collaborateur(numero_cree) else: return {"nom": nom_upper, "prenom": prenom, "status": "créé"} except ValueError as e: logger.warning(f"⚠️ Validation: {e}") raise except Exception as e: logger.error(f"❌ Erreur création collaborateur: {e}", exc_info=True) raise RuntimeError(f"Échec création collaborateur: {str(e)}") def modifier_collaborateur(self, numero: int, data: dict) -> dict: """Modifie un collaborateur existant via COM Sage (BOI)""" if not self.cial: raise RuntimeError("Connexion Sage non établie") logger.info(f"\n{'=' * 70}") logger.info(f"📝 MODIFICATION COLLABORATEUR N°{numero}") logger.info(f"{'=' * 70}") try: with self._com_context(), self._lock_com: # ===== LECTURE DU COLLABORATEUR EXISTANT ===== try: factory = self.cial.FactoryCollaborateur except AttributeError: factory = self.cial.CptaApplication.FactoryCollaborateur # Lire par numéro try: persist = factory.ReadNumero(numero) except Exception as e: raise ValueError(f"Collaborateur {numero} introuvable: {e}") if not persist: raise ValueError(f"Collaborateur {numero} introuvable") # Cast vers interface collab = None for iface in [ "IBOCollaborateur3", "IBOCollaborateur2", "IBOCollaborateur", ]: try: collab = win32com.client.CastTo(persist, iface) logger.info(f"✓ Cast vers {iface}") break except Exception: pass if not collab: collab = persist # Charger les données actuelles try: collab.Read() logger.info(f"✓ Collaborateur {numero} chargé") except Exception as e: logger.warning(f"Read() ignoré: {e}") # ===== HELPER ===== def safe_set(obj, attr, value, max_len=None): """Affecte une valeur de manière sécurisée""" if value is None: return False try: val = str(value) if not isinstance(value, bool) else value if max_len and isinstance(val, str): val = val[:max_len] setattr(obj, attr, val) logger.debug(f" ✓ {attr} = '{val}'") return True except Exception as e: logger.warning(f" ✗ {attr}: {e}") return False champs_modifies = [] # ===== CHAMPS DIRECTS SUR COLLABORATEUR ===== logger.info("📝 Champs directs...") champs_directs = { "nom": ("Nom", 35), "prenom": ("Prenom", 35), "fonction": ("Fonction", 35), "service": ("Service", 35), "matricule": ("Matricule", 10), "facebook": ("Facebook", 35), "linkedin": ("LinkedIn", 35), "skype": ("Skype", 35), } for py_field, (sage_attr, max_len) in champs_directs.items(): if py_field in data: val = data[py_field] # Cas spécial: nom en majuscules if py_field == "nom" and val: val = str(val).upper().strip() if safe_set(collab, sage_attr, val, max_len): champs_modifies.append(sage_attr) # ===== SOUS-OBJET ADRESSE ===== logger.info("📍 Adresse...") try: adresse_obj = collab.Adresse champs_adresse = { "adresse": ("Adresse", 35), "complement": ("Complement", 35), "code_postal": ("CodePostal", 9), "ville": ("Ville", 35), "code_region": ("CodeRegion", 25), "pays": ("Pays", 35), } for py_field, (sage_attr, max_len) in champs_adresse.items(): if py_field in data: if safe_set( adresse_obj, sage_attr, data[py_field], max_len ): champs_modifies.append(f"Adresse.{sage_attr}") except Exception as e: logger.warning(f"⚠️ Erreur accès Adresse: {e}") # ===== SOUS-OBJET TELECOM ===== logger.info("📞 Telecom...") try: telecom_obj = collab.Telecom champs_telecom = { "telephone": ("Telephone", 21), "telecopie": ("Telecopie", 21), "email": ("EMail", 69), "tel_portable": ("Portable", 21), } for py_field, (sage_attr, max_len) in champs_telecom.items(): if py_field in data: if safe_set( telecom_obj, sage_attr, data[py_field], max_len ): champs_modifies.append(f"Telecom.{sage_attr}") except Exception as e: logger.warning(f"⚠️ Erreur accès Telecom: {e}") # ===== CHAMPS BOOLÉENS ===== logger.info("🔘 Booléens...") champs_bool = { "vendeur": "Vendeur", "caissier": "Caissier", "acheteur": "Acheteur", "sommeil": "Sommeil", "chef_ventes": "ChefVentes", } for py_field, sage_attr in champs_bool.items(): if py_field in data and data[py_field] is not None: try: val = bool(data[py_field]) setattr(collab, sage_attr, val) champs_modifies.append(sage_attr) logger.debug(f" ✓ {sage_attr} = {val}") except Exception as e: logger.warning(f" ✗ {sage_attr}: {e}") # ===== VÉRIFICATION ===== if not champs_modifies: logger.info("ℹ️ Aucun champ à modifier") return self.lire_collaborateur(numero) logger.info( f"📋 {len(champs_modifies)} champ(s) à modifier: {champs_modifies}" ) # ===== WRITE ===== logger.info("💾 Write()...") try: collab.Write() logger.info("✅ Write() RÉUSSI!") except Exception as e: logger.error(f"❌ Write() échoué: {e}") raise RuntimeError(f"Échec Write(): {e}") # ===== RETOUR ===== logger.info(f"\n{'=' * 70}") logger.info(f"✅ COLLABORATEUR MODIFIÉ: N°{numero}") logger.info(f"{'=' * 70}") return self.lire_collaborateur(numero) except ValueError as e: logger.warning(f"⚠️ Validation: {e}") raise except Exception as e: logger.error(f"❌ Erreur modification collaborateur: {e}", exc_info=True) raise RuntimeError(f"Échec modification collaborateur: {str(e)}")