diff --git a/main.py b/main.py index 3fb0c5c..1c7b57a 100644 --- a/main.py +++ b/main.py @@ -541,7 +541,7 @@ def lire_devis(req: CodeRequest): @app.post("/sage/devis/list", dependencies=[Depends(verify_token)]) def devis_list( - limit: int = Query(100, description="Nombre max de devis"), + limit: int = Query(1000, description="Nombre max de devis"), statut: Optional[int] = Query(None, description="Filtrer par statut"), filtre: str = Query("", description="Filtre texte (numero, client)"), ): diff --git a/sage_connector.py b/sage_connector.py index 42a5364..980303b 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -1159,213 +1159,620 @@ class SageConnector: logger.error(f"❌ Erreur SQL article {reference}: {e}") return None - def _lire_document_sql(self, numero: str, type_doc: int): + def _convertir_type_pour_sql(self, type_doc: int) -> int: + """COM → SQL : 0, 10, 20, 30... → 0, 1, 2, 3...""" + mapping = {0: 0, 10: 1, 20: 2, 30: 3, 40: 4, 50: 5, 60: 6} + return mapping.get(type_doc, type_doc) + + def _convertir_type_depuis_sql(self, type_sql: int) -> int: + """SQL → COM : 0, 1, 2, 3... → 0, 10, 20, 30...""" + mapping = {0: 0, 1: 10, 2: 20, 3: 30, 4: 40, 5: 50, 6: 60} + return mapping.get(type_sql, type_sql) + + def _construire_liaisons_recursives( + self, + numero: str, + type_doc: int, + profondeur: int = 0, + max_profondeur: int = 5, + deja_visites: set = None, + ): + """ + Construit récursivement la structure des liaisons d'un document. + + Args: + numero: Numéro du document + type_doc: Type du document + profondeur: Profondeur actuelle de récursion + max_profondeur: Profondeur maximale pour éviter les boucles infinies + deja_visites: Set des documents déjà visités pour éviter les boucles + + Returns: + dict: Structure des liaisons avec origine et descendants + """ + if deja_visites is None: + deja_visites = set() + + # Éviter les boucles infinies + cle_doc = f"{numero}_{type_doc}" + if cle_doc in deja_visites or profondeur >= max_profondeur: + return None + + deja_visites.add(cle_doc) + try: with self._get_sql_connection() as conn: cursor = conn.cursor() # ======================================== - # VÉRIFIER SI DO_Domaine EXISTE + # 1. CHERCHER LE DOCUMENT ORIGINE (ascendant) # ======================================== - do_domaine_existe = False + origine = None - try: - cursor.execute( - "SELECT TOP 1 DO_Domaine FROM F_DOCENTETE WHERE DO_Type = ?", - (type_doc,), - ) - row_test = cursor.fetchone() - if row_test is not None: - do_domaine_existe = True - except: - do_domaine_existe = False - - # ======================================== - # LIRE L'ENTÊTE (avec filtre DO_Domaine si disponible) - # ======================================== - if do_domaine_existe: - query = """ - SELECT - DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC, - DO_Statut, DO_Tiers - FROM F_DOCENTETE - WHERE DO_Piece = ? AND DO_Type = ? AND DO_Domaine = 0 + cursor.execute( """ - else: - query = """ - SELECT - DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC, - DO_Statut, DO_Tiers + SELECT DISTINCT + DL_PieceDE, DL_PieceBC, DL_PieceBL + FROM F_DOCLIGNE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (numero, type_doc), + ) + lignes = cursor.fetchall() + + piece_origine = None + type_origine = None + + for ligne in lignes: + if ligne.DL_PieceDE and ligne.DL_PieceDE.strip(): + piece_origine = ligne.DL_PieceDE.strip() + type_origine = 0 # Devis + break + elif ligne.DL_PieceBC and ligne.DL_PieceBC.strip(): + piece_origine = ligne.DL_PieceBC.strip() + type_origine = 10 # Commande + break + elif ligne.DL_PieceBL and ligne.DL_PieceBL.strip(): + piece_origine = ligne.DL_PieceBL.strip() + type_origine = 30 # BL + break + + if piece_origine and type_origine is not None: + # Récupérer les infos de base du document origine + cursor.execute( + """ + SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut FROM F_DOCENTETE WHERE DO_Piece = ? AND DO_Type = ? - """ + """, + (piece_origine, type_origine), + ) + origine_row = cursor.fetchone() + + if origine_row: + origine = { + "numero": origine_row.DO_Piece.strip(), + "type": self._normaliser_type_document( + int(origine_row.DO_Type) + ), + "type_libelle": self._get_type_libelle( + int(origine_row.DO_Type) + ), + "ref": self._safe_strip(origine_row.DO_Ref), + "date": ( + str(origine_row.DO_Date) if origine_row.DO_Date else "" + ), + "total_ht": ( + float(origine_row.DO_TotalHT) + if origine_row.DO_TotalHT + else 0.0 + ), + "statut": ( + int(origine_row.DO_Statut) + if origine_row.DO_Statut + else 0 + ), + # Récursion sur l'origine + "liaisons": self._construire_liaisons_recursives( + origine_row.DO_Piece.strip(), + int(origine_row.DO_Type), + profondeur + 1, + max_profondeur, + deja_visites, + ), + } + + # ======================================== + # 2. CHERCHER LES DOCUMENTS DESCENDANTS + # ======================================== + descendants = [] + + # Utiliser la fonction existante pour trouver les transformations + verif = self.verifier_si_deja_transforme_sql(numero, type_doc) + + for doc_cible in verif["documents_cibles"]: + # Récupérer les infos complètes + cursor.execute( + """ + SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (doc_cible["numero"], doc_cible["type"]), + ) + desc_row = cursor.fetchone() + + if desc_row: + descendant = { + "numero": desc_row.DO_Piece.strip(), + "type": self._normaliser_type_document( + int(desc_row.DO_Type) + ), + "type_libelle": self._get_type_libelle( + int(desc_row.DO_Type) + ), + "ref": self._safe_strip(desc_row.DO_Ref), + "date": str(desc_row.DO_Date) if desc_row.DO_Date else "", + "total_ht": ( + float(desc_row.DO_TotalHT) + if desc_row.DO_TotalHT + else 0.0 + ), + "statut": ( + int(desc_row.DO_Statut) if desc_row.DO_Statut else 0 + ), + "nb_lignes": doc_cible.get("nb_lignes", 0), + # Récursion sur le descendant + "liaisons": self._construire_liaisons_recursives( + desc_row.DO_Piece.strip(), + int(desc_row.DO_Type), + profondeur + 1, + max_profondeur, + deja_visites, + ), + } + descendants.append(descendant) + + return {"origine": origine, "descendants": descendants} + + except Exception as e: + logger.error(f"Erreur construction liaisons pour {numero}: {e}") + return {"origine": None, "descendants": []} + + def _calculer_transformations_possibles(self, numero: str, type_doc: int): + """ + Calcule toutes les transformations possibles pour un document donné. + + Returns: + tuple: (peut_etre_transforme, transformations_possibles) + """ + type_doc = self._normaliser_type_document(type_doc) + + # Mapping des transformations autorisées + transformations_autorisees = { + 0: [10, 30, 60], # Devis → Commande, BL, Facture + 10: [30, 60], # Commande → BL, Facture + 30: [60], # BL → Facture + 50: [], # Avoir → rien + 60: [], # Facture → rien + } + + types_possibles = transformations_autorisees.get(type_doc, []) + transformations_possibles = [] + peut_etre_transforme = False + + for type_cible in types_possibles: + verif = self.peut_etre_transforme(numero, type_doc, type_cible) + + transformation = { + "type_cible": type_cible, + "type_libelle": self._get_type_libelle(type_cible), + "possible": verif["possible"], + } + + if not verif["possible"]: + transformation["raison"] = verif["raison"] + if verif.get("documents_existants"): + transformation["documents_existants"] = [ + d["numero"] for d in verif["documents_existants"] + ] + + transformations_possibles.append(transformation) + + if verif["possible"]: + peut_etre_transforme = True + + return peut_etre_transforme, transformations_possibles + + def _lire_document_sql(self, numero: str, type_doc: int): + """ + Lit un document spécifique par son numéro. + PAS de filtre par préfixe car on cherche un document précis. + """ + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + # ======================================== + # LIRE L'ENTÊTE (recherche directe, sans filtres restrictifs) + # ======================================== + query = """ + SELECT + d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, + d.DO_Statut, d.DO_Tiers, d.DO_DateLivr, d.DO_DateExpedition, + d.DO_Contact, d.DO_TotalHTNet, d.DO_NetAPayer, + d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte, + d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3, + d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3, + d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture, + d.DO_Transfere, d.DO_Souche, d.DO_PieceOrig, d.DO_GUID, + d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition, + d.DO_Tarif, d.DO_TypeFrais, d.DO_ValFrais, + d.DO_TypeFranco, d.DO_ValFranco, + c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal, + c.CT_Ville, c.CT_Telephone, c.CT_EMail + FROM F_DOCENTETE d + LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num + WHERE d.DO_Piece = ? AND d.DO_Type = ? + """ + + logger.info(f"[SQL READ] Lecture directe de {numero} (type={type_doc})") cursor.execute(query, (numero, type_doc)) row = cursor.fetchone() if not row: - # ✅ Si DO_Domaine n'existe pas, vérifier le préfixe - if not do_domaine_existe: - prefixes_vente = { - 0: ["DE"], - 10: ["BC"], - 30: ["BL"], - 50: ["AV", "AR"], - 60: ["FA", "FC"], - } - - prefixes_acceptes = prefixes_vente.get(type_doc, []) - est_vente = any( - numero.upper().startswith(p) for p in prefixes_acceptes - ) - - if not est_vente: - logger.warning( - f"Document {numero} semble être un document d'achat (préfixe non reconnu)" - ) - + logger.warning( + f"[SQL READ] ❌ Document {numero} (type={type_doc}) INTROUVABLE dans F_DOCENTETE" + ) return None - # Charger le client - client_code = self._safe_strip(row[6]) if row[6] else "" - client_intitule = "" - - if client_code: - cursor.execute( - """ - SELECT CT_Intitule - FROM F_COMPTET - WHERE CT_Num = ? - """, - (client_code,), - ) - - client_row = cursor.fetchone() - if client_row: - client_intitule = self._safe_strip(client_row[0]) + numero_piece = self._safe_strip(row[0]) + logger.info(f"[SQL READ] ✅ Document trouvé: {numero_piece}") # ======================================== - # LIRE LES LIGNES + # PAS DE FILTRE PAR PRÉFIXE ICI ! + # On retourne le document demandé, qu'il soit transformé ou non + # ======================================== + + # ======================================== + # CONSTRUIRE L'OBJET DOCUMENT + # ======================================== + doc = { + # Informations de base (indices 0-9) + "numero": numero_piece, + "reference": self._safe_strip(row[2]), # DO_Ref + "date": str(row[1]) if row[1] else "", # DO_Date + "date_livraison": ( + str(row[7]) if row[7] else "1753-01-01 00:00:00" + ), # DO_DateLivr + "date_expedition": ( + str(row[8]) if row[8] else "1753-01-01 00:00:00" + ), # DO_DateExpedition + # Client (indices 6 et 39-44) + "client_code": self._safe_strip(row[6]), # DO_Tiers + "client_intitule": self._safe_strip(row[39]), # CT_Intitule + "client_adresse": self._safe_strip(row[40]), # CT_Adresse + "client_code_postal": self._safe_strip(row[41]), # CT_CodePostal + "client_ville": self._safe_strip(row[42]), # CT_Ville + "client_telephone": self._safe_strip(row[43]), # CT_Telephone + "client_email": self._safe_strip(row[44]), # CT_EMail + "contact": self._safe_strip(row[9]), # DO_Contact + # Totaux (indices 3-4, 10-13) + "total_ht": float(row[3]) if row[3] else 0.0, # DO_TotalHT + "total_ht_net": float(row[10]) if row[10] else 0.0, # DO_TotalHTNet + "total_ttc": float(row[4]) if row[4] else 0.0, # DO_TotalTTC + "net_a_payer": float(row[11]) if row[11] else 0.0, # DO_NetAPayer + "montant_regle": ( + float(row[12]) if row[12] else 0.0 + ), # DO_MontantRegle + "reliquat": float(row[13]) if row[13] else 0.0, # DO_Reliquat + # Taxes (indices 14-21) + "taux_escompte": ( + float(row[14]) if row[14] else 0.0 + ), # DO_TxEscompte + "escompte": float(row[15]) if row[15] else 0.0, # DO_Escompte + "taxe1": float(row[16]) if row[16] else 0.0, # DO_Taxe1 + "taxe2": float(row[17]) if row[17] else 0.0, # DO_Taxe2 + "taxe3": float(row[18]) if row[18] else 0.0, # DO_Taxe3 + "code_taxe1": self._safe_strip(row[19]), # DO_CodeTaxe1 + "code_taxe2": self._safe_strip(row[20]), # DO_CodeTaxe2 + "code_taxe3": self._safe_strip(row[21]), # DO_CodeTaxe3 + # Statuts (indices 5, 22-26) + "statut": int(row[5]) if row[5] is not None else 0, # DO_Statut + "statut_estatut": ( + int(row[22]) if row[22] is not None else 0 + ), # DO_EStatut + "imprime": int(row[23]) if row[23] is not None else 0, # DO_Imprim + "valide": int(row[24]) if row[24] is not None else 0, # DO_Valide + "cloture": int(row[25]) if row[25] is not None else 0, # DO_Cloture + "transfere": ( + int(row[26]) if row[26] is not None else 0 + ), # DO_Transfere + # Autres (indices 27-38) + "souche": int(row[27]) if row[27] is not None else 0, # DO_Souche + "piece_origine": self._safe_strip(row[28]), # DO_PieceOrig + "guid": self._safe_strip(row[29]), # DO_GUID + "ca_num": self._safe_strip(row[30]), # CA_Num + "cg_num": self._safe_strip(row[31]), # CG_Num + "expedition": ( + int(row[32]) if row[32] is not None else 1 + ), # DO_Expedit + "condition": ( + int(row[33]) if row[33] is not None else 1 + ), # DO_Condition + "tarif": int(row[34]) if row[34] is not None else 1, # DO_Tarif + "type_frais": ( + int(row[35]) if row[35] is not None else 0 + ), # DO_TypeFrais + "valeur_frais": float(row[36]) if row[36] else 0.0, # DO_ValFrais + "type_franco": ( + int(row[37]) if row[37] is not None else 0 + ), # DO_TypeFranco + "valeur_franco": float(row[38]) if row[38] else 0.0, # DO_ValFranco + } + + # ======================================== + # CHARGER LES LIGNES # ======================================== cursor.execute( """ SELECT - AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT, - DL_Remise01REM_Valeur, DL_Remise01REM_Type - FROM F_DOCLIGNE - WHERE DO_Piece = ? AND DO_Type = ? - ORDER BY DL_Ligne - """, + dl.*, + a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch, + a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd, + a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen, + a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie + FROM F_DOCLIGNE dl + LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref + WHERE dl.DO_Piece = ? AND dl.DO_Type = ? + ORDER BY dl.DL_Ligne + """, (numero, type_doc), ) lignes = [] for ligne_row in cursor.fetchall(): + montant_ht = ( + float(ligne_row.DL_MontantHT) if ligne_row.DL_MontantHT else 0.0 + ) + montant_net = ( + float(ligne_row.DL_MontantNet) + if hasattr(ligne_row, "DL_MontantNet") + and ligne_row.DL_MontantNet + else montant_ht + ) + + taux_taxe1 = ( + float(ligne_row.DL_Taxe1) + if hasattr(ligne_row, "DL_Taxe1") and ligne_row.DL_Taxe1 + else 0.0 + ) + taux_taxe2 = ( + float(ligne_row.DL_Taxe2) + if hasattr(ligne_row, "DL_Taxe2") and ligne_row.DL_Taxe2 + else 0.0 + ) + taux_taxe3 = ( + float(ligne_row.DL_Taxe3) + if hasattr(ligne_row, "DL_Taxe3") and ligne_row.DL_Taxe3 + else 0.0 + ) + + total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3 + montant_ttc = montant_net * (1 + total_taux_taxes / 100) + + montant_taxe1 = montant_net * (taux_taxe1 / 100) + montant_taxe2 = montant_net * (taux_taxe2 / 100) + montant_taxe3 = montant_net * (taux_taxe3 / 100) + ligne = { - "article_code": self._safe_strip(ligne_row[0]), - "designation": self._safe_strip(ligne_row[1]), - "quantite": float(ligne_row[2]) if ligne_row[2] else 0.0, - "prix_unitaire_ht": ( - float(ligne_row[3]) if ligne_row[3] else 0.0 + "numero_ligne": ( + int(ligne_row.DL_Ligne) if ligne_row.DL_Ligne else 0 ), - "montant_ligne_ht": ( - float(ligne_row[4]) if ligne_row[4] else 0.0 + "article_code": self._safe_strip(ligne_row.AR_Ref), + "designation": self._safe_strip(ligne_row.DL_Design), + "designation_article": self._safe_strip(ligne_row.AR_Design), + "quantite": ( + float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0 + ), + "quantite_livree": ( + float(ligne_row.DL_QteLiv) + if hasattr(ligne_row, "DL_QteLiv") and ligne_row.DL_QteLiv + else 0.0 + ), + "quantite_reservee": ( + float(ligne_row.DL_QteRes) + if hasattr(ligne_row, "DL_QteRes") and ligne_row.DL_QteRes + else 0.0 + ), + "unite": ( + self._safe_strip(ligne_row.DL_Unite) + if hasattr(ligne_row, "DL_Unite") + else "" + ), + "prix_unitaire_ht": ( + float(ligne_row.DL_PrixUnitaire) + if ligne_row.DL_PrixUnitaire + else 0.0 + ), + "prix_unitaire_achat": ( + float(ligne_row.AR_PrixAch) if ligne_row.AR_PrixAch else 0.0 + ), + "prix_unitaire_vente": ( + float(ligne_row.AR_PrixVen) if ligne_row.AR_PrixVen else 0.0 + ), + "prix_unitaire_ttc": ( + float(ligne_row.AR_PrixTTC) if ligne_row.AR_PrixTTC else 0.0 + ), + "montant_ligne_ht": montant_ht, + "montant_ligne_net": montant_net, + "montant_ligne_ttc": montant_ttc, + "remise_valeur1": ( + float(ligne_row.DL_Remise01REM_Valeur) + if hasattr(ligne_row, "DL_Remise01REM_Valeur") + and ligne_row.DL_Remise01REM_Valeur + else 0.0 + ), + "remise_type1": ( + int(ligne_row.DL_Remise01REM_Type) + if hasattr(ligne_row, "DL_Remise01REM_Type") + and ligne_row.DL_Remise01REM_Type + else 0 + ), + "remise_valeur2": ( + float(ligne_row.DL_Remise02REM_Valeur) + if hasattr(ligne_row, "DL_Remise02REM_Valeur") + and ligne_row.DL_Remise02REM_Valeur + else 0.0 + ), + "remise_type2": ( + int(ligne_row.DL_Remise02REM_Type) + if hasattr(ligne_row, "DL_Remise02REM_Type") + and ligne_row.DL_Remise02REM_Type + else 0 + ), + "remise_article": ( + float(ligne_row.AR_Escompte) + if ligne_row.AR_Escompte + else 0.0 + ), + "taux_taxe1": taux_taxe1, + "montant_taxe1": montant_taxe1, + "taux_taxe2": taux_taxe2, + "montant_taxe2": montant_taxe2, + "taux_taxe3": taux_taxe3, + "montant_taxe3": montant_taxe3, + "total_taxes": montant_taxe1 + montant_taxe2 + montant_taxe3, + "famille_article": self._safe_strip(ligne_row.FA_CodeFamille), + "gamme1": self._safe_strip(ligne_row.AR_Gamme1), + "gamme2": self._safe_strip(ligne_row.AR_Gamme2), + "code_barre": self._safe_strip(ligne_row.AR_CodeBarre), + "type_article": self._safe_strip(ligne_row.AR_Type), + "nature_article": self._safe_strip(ligne_row.AR_Nature), + "garantie": self._safe_strip(ligne_row.AR_Garantie), + "cout_standard": ( + float(ligne_row.AR_CoutStd) if ligne_row.AR_CoutStd else 0.0 + ), + "poids_net": ( + float(ligne_row.AR_PoidsNet) + if ligne_row.AR_PoidsNet + else 0.0 + ), + "poids_brut": ( + float(ligne_row.AR_PoidsBrut) + if ligne_row.AR_PoidsBrut + else 0.0 + ), + "unite_vente": self._safe_strip(ligne_row.AR_UniteVen), + "date_livraison_ligne": ( + str(ligne_row.DL_DateLivr) + if hasattr(ligne_row, "DL_DateLivr") + and ligne_row.DL_DateLivr + else "" + ), + "statut_ligne": ( + int(ligne_row.DL_Statut) + if hasattr(ligne_row, "DL_Statut") + and ligne_row.DL_Statut is not None + else 0 + ), + "depot": ( + self._safe_strip(ligne_row.DE_No) + if hasattr(ligne_row, "DE_No") + else "" + ), + "numero_commande": ( + self._safe_strip(ligne_row.DL_NoColis) + if hasattr(ligne_row, "DL_NoColis") + else "" + ), + "num_colis": ( + self._safe_strip(ligne_row.DL_Colis) + if hasattr(ligne_row, "DL_Colis") + else "" ), } - - # Remise (si présente) - if ligne_row[5]: - ligne["remise_pourcentage"] = float(ligne_row[5]) - ligne["remise_type"] = int(ligne_row[6]) if ligne_row[6] else 0 - else: - ligne["remise_pourcentage"] = 0.0 - ligne["remise_type"] = 0 - lignes.append(ligne) - return { - "numero": self._safe_strip(row[0]), - "reference": self._safe_strip(row[2]), - "date": str(row[1]) if row[1] else "", - "client_code": client_code, - "client_intitule": client_intitule, - "total_ht": float(row[3]) if row[3] else 0.0, - "total_ttc": float(row[4]) if row[4] else 0.0, - "statut": row[5] if row[5] is not None else 0, - "lignes": lignes, - "nb_lignes": len(lignes), - } + doc["lignes"] = lignes + doc["nb_lignes"] = len(lignes) + + # Totaux calculés + total_ht_calcule = sum(l.get("montant_ligne_ht", 0) for l in lignes) + total_ttc_calcule = sum(l.get("montant_ligne_ttc", 0) for l in lignes) + total_taxes_calcule = sum(l.get("total_taxes", 0) for l in lignes) + + doc["total_ht_calcule"] = total_ht_calcule + doc["total_ttc_calcule"] = total_ttc_calcule + doc["total_taxes_calcule"] = total_taxes_calcule + + # ======================================== + # AJOUTER LES DOCUMENTS LIÉS (RÉCURSIF) + # ======================================== + logger.info(f"Construction liaisons récursives pour {numero}...") + doc["documents_lies"] = self._construire_liaisons_recursives( + numero, type_doc + ) + + # ======================================== + # AJOUTER LES TRANSFORMATIONS POSSIBLES + # ======================================== + logger.info(f"Calcul transformations possibles pour {numero}...") + peut_etre_transforme, transformations_possibles = ( + self._calculer_transformations_possibles(numero, type_doc) + ) + + doc["peut_etre_transforme"] = peut_etre_transforme + doc["transformations_possibles"] = transformations_possibles + + return doc except Exception as e: - logger.error(f"❌ Erreur SQL lecture document {numero}: {e}") + logger.error(f"❌ Erreur SQL lecture document {numero}: {e}", exc_info=True) return None def _lister_documents_avec_lignes_sql( - self, type_doc: int, filtre: str = "", limit: int = None + self, + type_doc: int, + filtre: str = "", + limit: int = None, + inclure_liaisons: bool = False, + calculer_transformations: bool = True, ): + """Liste les documents avec leurs lignes.""" try: + # Convertir le type pour SQL + type_doc_sql = self._convertir_type_pour_sql(type_doc) + logger.info(f"[SQL LIST] ═══ Type COM {type_doc} → SQL {type_doc_sql} ═══") + with self._get_sql_connection() as conn: cursor = conn.cursor() - # ======================================== - # ÉTAPE 0 : DIAGNOSTIC - Vérifier si DO_Domaine existe - # ======================================== - do_domaine_existe = False + query = """ + SELECT DISTINCT + d.DO_Piece, d.DO_Type, d.DO_Date, d.DO_Ref, d.DO_Tiers, + d.DO_TotalHT, d.DO_TotalTTC, d.DO_NetAPayer, d.DO_Statut, + d.DO_DateLivr, d.DO_DateExpedition, d.DO_Contact, d.DO_TotalHTNet, + d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte, + d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3, + d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3, + d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture, d.DO_Transfere, + d.DO_Souche, d.DO_PieceOrig, d.DO_GUID, + d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition, d.DO_Tarif, + d.DO_TypeFrais, d.DO_ValFrais, d.DO_TypeFranco, d.DO_ValFranco, + c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal, + c.CT_Ville, c.CT_Telephone, c.CT_EMail + FROM F_DOCENTETE d + LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num + WHERE d.DO_Type = ? + """ - try: - cursor.execute( - """ - SELECT TOP 1 DO_Domaine - FROM F_DOCENTETE - WHERE DO_Type = ? - """, - (type_doc,), - ) - - row = cursor.fetchone() - if row is not None: - do_domaine_existe = True - logger.info( - f"[SQL] Colonne DO_Domaine détectée (valeur exemple: {row[0]})" - ) - except Exception as e: - logger.info(f"[SQL] Colonne DO_Domaine non disponible: {e}") - do_domaine_existe = False - - # ======================================== - # ÉTAPE 1 : CONSTRUIRE LA REQUÊTE SELON DISPONIBILITÉ DO_Domaine - # ======================================== - if do_domaine_existe: - # Version avec filtre DO_Domaine - query = """ - SELECT - d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, - d.DO_Statut, d.DO_Tiers, c.CT_Intitule - FROM F_DOCENTETE d - LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num - WHERE d.DO_Type = ? - AND d.DO_Domaine = 0 - """ - logger.info(f"[SQL] Requête AVEC filtre DO_Domaine = 0") - else: - # Version SANS filtre DO_Domaine (utilise heuristique) - query = """ - SELECT - d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC, - d.DO_Statut, d.DO_Tiers, c.CT_Intitule - FROM F_DOCENTETE d - LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num - WHERE d.DO_Type = ? - """ - logger.warning( - f"[SQL] Requête SANS filtre DO_Domaine (heuristique sur préfixe)" - ) - - params = [type_doc] + params = [type_doc_sql] if filtre: - query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ?)" - params.extend([f"%{filtre}%", f"%{filtre}%"]) + query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ? OR d.DO_Ref LIKE ?)" + params.extend([f"%{filtre}%", f"%{filtre}%", f"%{filtre}%"]) query += " ORDER BY d.DO_Date DESC" @@ -1375,134 +1782,556 @@ class SageConnector: cursor.execute(query, params) entetes = cursor.fetchall() - logger.info( - f"[SQL] {len(entetes)} documents bruts récupérés (type={type_doc})" - ) + logger.info(f"[SQL LIST] 📊 {len(entetes)} documents SQL") documents = [] + stats = { + "total": len(entetes), + "exclus_prefixe": 0, + "erreur_construction": 0, + "erreur_lignes": 0, + "erreur_transformations": 0, + "erreur_liaisons": 0, + "succes": 0, + } - # ======================================== - # ÉTAPE 2 : FILTRER PAR HEURISTIQUE SI DO_Domaine N'EXISTE PAS - # ======================================== - for entete in entetes: + for idx, entete in enumerate(entetes): numero = self._safe_strip(entete.DO_Piece) + logger.info( + f"[SQL LIST] [{idx+1}/{len(entetes)}] 🔄 Traitement {numero}..." + ) - # Si DO_Domaine n'existe pas, filtrer par préfixe du numéro - if not do_domaine_existe: - # Heuristique : - # - Vente (clients) : BC, BL, FA, AV, DE - # - Achat (fournisseurs) : DA, RA, FAF, etc. - + try: + # ======================================== + # ÉTAPE 1 : FILTRE PRÉFIXE + # ======================================== prefixes_vente = { - 0: ["DE"], # Devis - 10: ["BC"], # Bon de commande - 30: ["BL"], # Bon de livraison - 50: ["AV", "AR"], # Avoir - 60: ["FA", "FC"], # Facture + 0: ["DE"], + 10: ["BC"], + 30: ["BL"], + 50: ["AV", "AR"], + 60: ["FA", "FC"], } prefixes_acceptes = prefixes_vente.get(type_doc, []) if prefixes_acceptes: - # Vérifier si le numéro commence par un préfixe valide est_vente = any( numero.upper().startswith(p) for p in prefixes_acceptes ) - if not est_vente: - logger.debug( - f"[SQL] Document {numero} exclu (préfixe achat)" + logger.info( + f"[SQL LIST] ❌ {numero} : exclu (préfixe achat)" ) + stats["exclus_prefixe"] += 1 continue - # Créer l'objet document de base - doc = { - "numero": numero, - "reference": self._safe_strip(entete.DO_Ref), - "date": str(entete.DO_Date) if entete.DO_Date else "", - "client_code": self._safe_strip(entete.DO_Tiers), - "client_intitule": self._safe_strip(entete.CT_Intitule), - "total_ht": ( - float(entete.DO_TotalHT) if entete.DO_TotalHT else 0.0 - ), - "total_ttc": ( - float(entete.DO_TotalTTC) if entete.DO_TotalTTC else 0.0 - ), - "statut": ( - entete.DO_Statut if entete.DO_Statut is not None else 0 - ), - "lignes": [], - } + logger.debug(f"[SQL LIST] ✅ {numero} : préfixe OK") - # ======================================== - # CHARGER LES LIGNES - # ======================================== - try: - cursor.execute( - """ - SELECT - AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT, - DL_Remise01REM_Valeur, DL_Remise01REM_Type - FROM F_DOCLIGNE - WHERE DO_Piece = ? AND DO_Type = ? - ORDER BY DL_Ligne - """, - (numero, type_doc), - ) + # ======================================== + # ÉTAPE 2 : CONSTRUIRE DOCUMENT DE BASE + # ======================================== + try: + type_doc_depuis_sql = self._convertir_type_depuis_sql( + int(entete.DO_Type) + ) - lignes_rows = cursor.fetchall() - - for ligne_row in lignes_rows: - ligne = { - "article_code": self._safe_strip(ligne_row.AR_Ref), - "designation": self._safe_strip(ligne_row.DL_Design), - "quantite": ( - float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0 + doc = { + "numero": numero, + "type": type_doc_depuis_sql, + "reference": self._safe_strip(entete.DO_Ref), + "date": str(entete.DO_Date) if entete.DO_Date else "", + "date_livraison": ( + str(entete.DO_DateLivr) + if entete.DO_DateLivr + else "" ), - "prix_unitaire_ht": ( - float(ligne_row.DL_PrixUnitaire) - if ligne_row.DL_PrixUnitaire + "date_expedition": ( + str(entete.DO_DateExpedition) + if entete.DO_DateExpedition + else "" + ), + "client_code": self._safe_strip(entete.DO_Tiers), + "client_intitule": self._safe_strip(entete.CT_Intitule), + "client_adresse": self._safe_strip(entete.CT_Adresse), + "client_code_postal": self._safe_strip( + entete.CT_CodePostal + ), + "client_ville": self._safe_strip(entete.CT_Ville), + "client_telephone": self._safe_strip( + entete.CT_Telephone + ), + "client_email": self._safe_strip(entete.CT_EMail), + "contact": self._safe_strip(entete.DO_Contact), + "total_ht": ( + float(entete.DO_TotalHT) + if entete.DO_TotalHT else 0.0 ), - "montant_ligne_ht": ( + "total_ht_net": ( + float(entete.DO_TotalHTNet) + if entete.DO_TotalHTNet + else 0.0 + ), + "total_ttc": ( + float(entete.DO_TotalTTC) + if entete.DO_TotalTTC + else 0.0 + ), + "net_a_payer": ( + float(entete.DO_NetAPayer) + if entete.DO_NetAPayer + else 0.0 + ), + "montant_regle": ( + float(entete.DO_MontantRegle) + if entete.DO_MontantRegle + else 0.0 + ), + "reliquat": ( + float(entete.DO_Reliquat) + if entete.DO_Reliquat + else 0.0 + ), + "taux_escompte": ( + float(entete.DO_TxEscompte) + if entete.DO_TxEscompte + else 0.0 + ), + "escompte": ( + float(entete.DO_Escompte) + if entete.DO_Escompte + else 0.0 + ), + "taxe1": ( + float(entete.DO_Taxe1) if entete.DO_Taxe1 else 0.0 + ), + "taxe2": ( + float(entete.DO_Taxe2) if entete.DO_Taxe2 else 0.0 + ), + "taxe3": ( + float(entete.DO_Taxe3) if entete.DO_Taxe3 else 0.0 + ), + "code_taxe1": self._safe_strip(entete.DO_CodeTaxe1), + "code_taxe2": self._safe_strip(entete.DO_CodeTaxe2), + "code_taxe3": self._safe_strip(entete.DO_CodeTaxe3), + "statut": ( + int(entete.DO_Statut) + if entete.DO_Statut is not None + else 0 + ), + "statut_estatut": ( + int(entete.DO_EStatut) + if entete.DO_EStatut is not None + else 0 + ), + "imprime": ( + int(entete.DO_Imprim) + if entete.DO_Imprim is not None + else 0 + ), + "valide": ( + int(entete.DO_Valide) + if entete.DO_Valide is not None + else 0 + ), + "cloture": ( + int(entete.DO_Cloture) + if entete.DO_Cloture is not None + else 0 + ), + "transfere": ( + int(entete.DO_Transfere) + if entete.DO_Transfere is not None + else 0 + ), + "souche": self._safe_strip(entete.DO_Souche), + "piece_origine": self._safe_strip(entete.DO_PieceOrig), + "guid": self._safe_strip(entete.DO_GUID), + "ca_num": self._safe_strip(entete.CA_Num), + "cg_num": self._safe_strip(entete.CG_Num), + "expedition": self._safe_strip(entete.DO_Expedit), + "condition": self._safe_strip(entete.DO_Condition), + "tarif": self._safe_strip(entete.DO_Tarif), + "type_frais": ( + int(entete.DO_TypeFrais) + if entete.DO_TypeFrais is not None + else 0 + ), + "valeur_frais": ( + float(entete.DO_ValFrais) + if entete.DO_ValFrais + else 0.0 + ), + "type_franco": ( + int(entete.DO_TypeFranco) + if entete.DO_TypeFranco is not None + else 0 + ), + "valeur_franco": ( + float(entete.DO_ValFranco) + if entete.DO_ValFranco + else 0.0 + ), + "lignes": [], + } + + logger.debug( + f"[SQL LIST] ✅ {numero} : document de base créé" + ) + + except Exception as e: + logger.error( + f"[SQL LIST] ❌ {numero} : ERREUR construction base: {e}", + exc_info=True, + ) + stats["erreur_construction"] += 1 + continue + + # ======================================== + # ÉTAPE 3 : CHARGER LES LIGNES + # ======================================== + try: + cursor.execute( + """ + SELECT dl.*, + a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch, + a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd, + a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen, + a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie + FROM F_DOCLIGNE dl + LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref + WHERE dl.DO_Piece = ? AND dl.DO_Type = ? + ORDER BY dl.DL_Ligne + """, + (numero, type_doc_sql), + ) + + for ligne_row in cursor.fetchall(): + montant_ht = ( float(ligne_row.DL_MontantHT) if ligne_row.DL_MontantHT else 0.0 - ), - } - - # Remise (si présente) - if ligne_row.DL_Remise01REM_Valeur: - ligne["remise_pourcentage"] = float( - ligne_row.DL_Remise01REM_Valeur ) - ligne["remise_type"] = ( - int(ligne_row.DL_Remise01REM_Type) - if ligne_row.DL_Remise01REM_Type - else 0 + montant_net = ( + float(ligne_row.DL_MontantNet) + if hasattr(ligne_row, "DL_MontantNet") + and ligne_row.DL_MontantNet + else montant_ht ) - else: - ligne["remise_pourcentage"] = 0.0 - ligne["remise_type"] = 0 - doc["lignes"].append(ligne) + taux_taxe1 = ( + float(ligne_row.DL_Taxe1) + if hasattr(ligne_row, "DL_Taxe1") + and ligne_row.DL_Taxe1 + else 0.0 + ) + taux_taxe2 = ( + float(ligne_row.DL_Taxe2) + if hasattr(ligne_row, "DL_Taxe2") + and ligne_row.DL_Taxe2 + else 0.0 + ) + taux_taxe3 = ( + float(ligne_row.DL_Taxe3) + if hasattr(ligne_row, "DL_Taxe3") + and ligne_row.DL_Taxe3 + else 0.0 + ) + + total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3 + montant_ttc = montant_net * (1 + total_taux_taxes / 100) + + montant_taxe1 = montant_net * (taux_taxe1 / 100) + montant_taxe2 = montant_net * (taux_taxe2 / 100) + montant_taxe3 = montant_net * (taux_taxe3 / 100) + + ligne = { + "numero_ligne": ( + int(ligne_row.DL_Ligne) + if ligne_row.DL_Ligne + else 0 + ), + "article_code": self._safe_strip(ligne_row.AR_Ref), + "designation": self._safe_strip( + ligne_row.DL_Design + ), + "designation_article": self._safe_strip( + ligne_row.AR_Design + ), + "quantite": ( + float(ligne_row.DL_Qte) + if ligne_row.DL_Qte + else 0.0 + ), + "quantite_livree": ( + float(ligne_row.DL_QteLiv) + if hasattr(ligne_row, "DL_QteLiv") + and ligne_row.DL_QteLiv + else 0.0 + ), + "quantite_reservee": ( + float(ligne_row.DL_QteRes) + if hasattr(ligne_row, "DL_QteRes") + and ligne_row.DL_QteRes + else 0.0 + ), + "unite": ( + self._safe_strip(ligne_row.DL_Unite) + if hasattr(ligne_row, "DL_Unite") + else "" + ), + "prix_unitaire_ht": ( + float(ligne_row.DL_PrixUnitaire) + if ligne_row.DL_PrixUnitaire + else 0.0 + ), + "prix_unitaire_achat": ( + float(ligne_row.AR_PrixAch) + if ligne_row.AR_PrixAch + else 0.0 + ), + "prix_unitaire_vente": ( + float(ligne_row.AR_PrixVen) + if ligne_row.AR_PrixVen + else 0.0 + ), + "prix_unitaire_ttc": ( + float(ligne_row.AR_PrixTTC) + if ligne_row.AR_PrixTTC + else 0.0 + ), + "montant_ligne_ht": montant_ht, + "montant_ligne_net": montant_net, + "montant_ligne_ttc": montant_ttc, + "remise_valeur1": ( + float(ligne_row.DL_Remise01REM_Valeur) + if hasattr(ligne_row, "DL_Remise01REM_Valeur") + and ligne_row.DL_Remise01REM_Valeur + else 0.0 + ), + "remise_type1": ( + int(ligne_row.DL_Remise01REM_Type) + if hasattr(ligne_row, "DL_Remise01REM_Type") + and ligne_row.DL_Remise01REM_Type + else 0 + ), + "remise_valeur2": ( + float(ligne_row.DL_Remise02REM_Valeur) + if hasattr(ligne_row, "DL_Remise02REM_Valeur") + and ligne_row.DL_Remise02REM_Valeur + else 0.0 + ), + "remise_type2": ( + int(ligne_row.DL_Remise02REM_Type) + if hasattr(ligne_row, "DL_Remise02REM_Type") + and ligne_row.DL_Remise02REM_Type + else 0 + ), + "remise_article": ( + float(ligne_row.AR_Escompte) + if ligne_row.AR_Escompte + else 0.0 + ), + "taux_taxe1": taux_taxe1, + "montant_taxe1": montant_taxe1, + "taux_taxe2": taux_taxe2, + "montant_taxe2": montant_taxe2, + "taux_taxe3": taux_taxe3, + "montant_taxe3": montant_taxe3, + "total_taxes": montant_taxe1 + + montant_taxe2 + + montant_taxe3, + "famille_article": self._safe_strip( + ligne_row.FA_CodeFamille + ), + "gamme1": self._safe_strip(ligne_row.AR_Gamme1), + "gamme2": self._safe_strip(ligne_row.AR_Gamme2), + "code_barre": self._safe_strip( + ligne_row.AR_CodeBarre + ), + "type_article": self._safe_strip(ligne_row.AR_Type), + "nature_article": self._safe_strip( + ligne_row.AR_Nature + ), + "garantie": self._safe_strip(ligne_row.AR_Garantie), + "cout_standard": ( + float(ligne_row.AR_CoutStd) + if ligne_row.AR_CoutStd + else 0.0 + ), + "poids_net": ( + float(ligne_row.AR_PoidsNet) + if ligne_row.AR_PoidsNet + else 0.0 + ), + "poids_brut": ( + float(ligne_row.AR_PoidsBrut) + if ligne_row.AR_PoidsBrut + else 0.0 + ), + "unite_vente": self._safe_strip( + ligne_row.AR_UniteVen + ), + "date_livraison_ligne": ( + str(ligne_row.DL_DateLivr) + if hasattr(ligne_row, "DL_DateLivr") + and ligne_row.DL_DateLivr + else "" + ), + "statut_ligne": ( + int(ligne_row.DL_Statut) + if hasattr(ligne_row, "DL_Statut") + and ligne_row.DL_Statut is not None + else 0 + ), + "depot": ( + self._safe_strip(ligne_row.DE_No) + if hasattr(ligne_row, "DE_No") + else "" + ), + "numero_commande": ( + self._safe_strip(ligne_row.DL_NoColis) + if hasattr(ligne_row, "DL_NoColis") + else "" + ), + "num_colis": ( + self._safe_strip(ligne_row.DL_Colis) + if hasattr(ligne_row, "DL_Colis") + else "" + ), + } + doc["lignes"].append(ligne) + + doc["nb_lignes"] = len(doc["lignes"]) + doc["total_ht_calcule"] = sum( + l.get("montant_ligne_ht", 0) for l in doc["lignes"] + ) + doc["total_ttc_calcule"] = sum( + l.get("montant_ligne_ttc", 0) for l in doc["lignes"] + ) + doc["total_taxes_calcule"] = sum( + l.get("total_taxes", 0) for l in doc["lignes"] + ) + + logger.debug( + f"[SQL LIST] ✅ {numero} : {doc['nb_lignes']} lignes chargées" + ) + + except Exception as e: + logger.error( + f"[SQL LIST] ⚠️ {numero} : ERREUR lignes: {e}", + exc_info=True, + ) + stats["erreur_lignes"] += 1 + # Continuer quand même avec 0 lignes + + # ======================================== + # ÉTAPE 4 : TRANSFORMATIONS + # ======================================== + if calculer_transformations: + try: + logger.debug( + f"[SQL LIST] 🔄 {numero} : calcul transformations..." + ) + + peut_etre_transforme, transformations_possibles = ( + self._calculer_transformations_possibles( + doc["numero"], type_doc # Type COM + ) + ) + doc["peut_etre_transforme"] = peut_etre_transforme + doc["transformations_possibles"] = ( + transformations_possibles + ) + + logger.info( + f"[SQL LIST] ✅ {numero} : peut_etre_transforme={peut_etre_transforme}, " + f"{len(transformations_possibles)} transformations" + ) + + except Exception as e: + logger.error( + f"[SQL LIST] ❌ {numero} : ERREUR TRANSFORMATIONS: {e}", + exc_info=True, + ) + stats["erreur_transformations"] += 1 + doc["peut_etre_transforme"] = False + doc["transformations_possibles"] = [] + doc["erreur_transformations"] = str(e) + else: + doc["peut_etre_transforme"] = False + doc["transformations_possibles"] = [] + + # ======================================== + # ÉTAPE 5 : LIAISONS + # ======================================== + if inclure_liaisons: + try: + logger.debug( + f"[SQL LIST] 🔄 {numero} : construction liaisons..." + ) + + doc["documents_lies"] = ( + self._construire_liaisons_recursives( + doc["numero"], type_doc + ) + ) + + logger.debug(f"[SQL LIST] ✅ {numero} : liaisons OK") + + except Exception as e: + logger.error( + f"[SQL LIST] ⚠️ {numero} : ERREUR liaisons: {e}", + exc_info=True, + ) + stats["erreur_liaisons"] += 1 + doc["documents_lies"] = { + "origine": None, + "descendants": [], + } + else: + doc["documents_lies"] = {"origine": None, "descendants": []} + + # ======================================== + # ÉTAPE 6 : AJOUT DU DOCUMENT + # ======================================== + documents.append(doc) + stats["succes"] += 1 + logger.info( + f"[SQL LIST] ✅✅✅ {numero} : AJOUTÉ à la liste (total: {len(documents)})" + ) except Exception as e: - logger.warning(f"Erreur chargement lignes pour {numero}: {e}") + logger.error( + f"[SQL LIST] ❌❌❌ {numero} : EXCEPTION GLOBALE - DOCUMENT EXCLU: {e}", + exc_info=True, + ) + continue - # Ajouter le nombre de lignes - doc["nb_lignes"] = len(doc["lignes"]) - - documents.append(doc) - - methode = "DO_Domaine" if do_domaine_existe else "heuristique prefixe" + # ======================================== + # RÉSUMÉ FINAL + # ======================================== + logger.info(f"[SQL LIST] ═══════════════════════════") + logger.info(f"[SQL LIST] 📊 STATISTIQUES FINALES:") + logger.info(f"[SQL LIST] Total SQL: {stats['total']}") + logger.info(f"[SQL LIST] Exclus préfixe: {stats['exclus_prefixe']}") logger.info( - f"SQL: {len(documents)} documents (type={type_doc}, filtre={methode})" + f"[SQL LIST] Erreur construction: {stats['erreur_construction']}" ) + logger.info(f"[SQL LIST] Erreur lignes: {stats['erreur_lignes']}") + logger.info( + f"[SQL LIST] Erreur transformations: {stats['erreur_transformations']}" + ) + logger.info(f"[SQL LIST] Erreur liaisons: {stats['erreur_liaisons']}") + logger.info(f"[SQL LIST] ✅ SUCCÈS: {stats['succes']}") + logger.info(f"[SQL LIST] 📦 Documents retournés: {len(documents)}") + logger.info(f"[SQL LIST] ═══════════════════════════") + return documents except Exception as e: - logger.error(f"Erreur SQL listage documents avec lignes: {e}") + logger.error(f"❌ Erreur GLOBALE listage: {e}", exc_info=True) return [] def lister_tous_devis_cache(self, filtre=""): @@ -2888,21 +3717,6 @@ class SageConnector: if not devis: return None - # ✅ Vérifier si transformé (via SQL pour DO_Ref) - try: - verification = self.verifier_si_deja_transforme_sql(numero_devis, 0) - devis["a_deja_ete_transforme"] = verification.get( - "deja_transforme", False - ) - devis["documents_cibles"] = verification.get("documents_cibles", []) - except Exception as e: - logger.warning(f"⚠️ Erreur vérification transformation: {e}") - devis["a_deja_ete_transforme"] = False - devis["documents_cibles"] = [] - - logger.info( - f"✅ SQL: Devis {numero_devis} lu ({len(devis['lignes'])} lignes)" - ) return devis except Exception as e: @@ -2912,88 +3726,450 @@ class SageConnector: def lire_document(self, numero, type_doc): return self._lire_document_sql(numero, type_doc) - def verifier_si_deja_transforme_sql( - self, numero_source: str, type_source: int - ) -> Dict: + def verifier_si_deja_transforme_sql(self, numero_source, type_source): + """Version corrigée avec normalisation des types""" + logger.info( + f"[VERIF] Vérification transformations de {numero_source} (type {type_source})" + ) + + logger.info( + f"[VERIF] Vérification transformations de {numero_source} (type {type_source})" + ) + + # DEBUG COMPLET + logger.info(f"[DEBUG] Type source brut: {type_source}") + logger.info( + f"[DEBUG] Type source après normalisation: {self._normaliser_type_document(type_source)}" + ) + logger.info( + f"[DEBUG] Type source après normalisation SQL: {self._convertir_type_pour_sql(type_source)}" + ) + + # NORMALISER le type source + type_source = self._convertir_type_pour_sql(type_source) + + champ_liaison_mapping = { + 0: "DL_PieceDE", + 1: "DL_PieceBC", + 3: "DL_PieceBL", + } + + champ_liaison = champ_liaison_mapping.get(type_source) + + if not champ_liaison: + logger.warning(f"[VERIF] Type source {type_source} non géré") + return {"deja_transforme": False, "documents_cibles": []} + try: with self._get_sql_connection() as conn: cursor = conn.cursor() - # Types cibles selon le type source - types_cibles_map = { - 0: [10, 60], # Devis → Commande ou Facture - 10: [30, 60], # Commande → BL ou Facture - 30: [60], # BL → Facture - } - - types_cibles = types_cibles_map.get(type_source, []) - - if not types_cibles: - return {"deja_transforme": False, "documents_cibles": []} - - # Construire la clause WHERE avec OR pour chaque type - placeholders = ",".join(["?"] * len(types_cibles)) - query = f""" - SELECT DO_Piece, DO_Type, DO_Date, DO_Ref, DO_TotalTTC, DO_Statut - FROM F_DOCENTETE - WHERE DO_Ref LIKE ? - AND DO_Type IN ({placeholders}) - ORDER BY DO_Date DESC + SELECT DISTINCT + dc.DO_Piece, + dc.DO_Type, + dc.DO_Statut, + (SELECT COUNT(*) FROM F_DOCLIGNE + WHERE DO_Piece = dc.DO_Piece AND DO_Type = dc.DO_Type) as NbLignes + FROM F_DOCENTETE dc + INNER JOIN F_DOCLIGNE dl ON dc.DO_Piece = dl.DO_Piece AND dc.DO_Type = dl.DO_Type + WHERE dl.{champ_liaison} = ? + ORDER BY dc.DO_Type, dc.DO_Piece """ - params = [f"%{numero_source}%"] + types_cibles - - cursor.execute(query, params) - rows = cursor.fetchall() + cursor.execute(query, (numero_source,)) + resultats = cursor.fetchall() documents_cibles = [] + for row in resultats: + type_brut = int(row.DO_Type) + type_normalise = self._convertir_type_depuis_sql(type_brut) - for row in rows: - # Vérifier que DO_Ref correspond exactement (éviter les faux positifs) - ref_origine = self._safe_strip(row.DO_Ref) + doc = { + "numero": row.DO_Piece.strip() if row.DO_Piece else "", + "type": type_normalise, # ← TYPE NORMALISÉ + "type_brut": type_brut, # Garder aussi le type original + "type_libelle": self._get_type_libelle(type_brut), + "statut": int(row.DO_Statut) if row.DO_Statut else 0, + "nb_lignes": int(row.NbLignes) if row.NbLignes else 0, + } + documents_cibles.append(doc) + logger.info( + f"[VERIF] Trouvé: {doc['numero']} " + f"(type {type_brut}→{type_normalise} - {doc['type_libelle']}) " + f"- {doc['nb_lignes']} lignes" + ) - if numero_source in ref_origine or ref_origine == numero_source: - type_libelle = { - 10: "Bon de commande", - 30: "Bon de livraison", - 60: "Facture", - }.get(row.DO_Type, f"Type {row.DO_Type}") + deja_transforme = len(documents_cibles) > 0 - documents_cibles.append( - { - "numero": self._safe_strip(row.DO_Piece), - "type": row.DO_Type, - "type_libelle": type_libelle, - "date": str(row.DO_Date) if row.DO_Date else "", - "reference": ref_origine, - "total_ttc": ( - float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0 - ), - "statut": ( - row.DO_Statut if row.DO_Statut is not None else 0 - ), - "methode_detection": "sql_do_ref", - } - ) - - logger.info( - f"✅ SQL: Vérification {numero_source} → {len(documents_cibles)} transformation(s)" - ) + if deja_transforme: + logger.info( + f"[VERIF] ✅ Document {numero_source} a {len(documents_cibles)} transformation(s)" + ) + else: + logger.info( + f"[VERIF] ℹ️ Document {numero_source} pas encore transformé" + ) return { - "deja_transforme": len(documents_cibles) > 0, - "nb_transformations": len(documents_cibles), + "deja_transforme": deja_transforme, "documents_cibles": documents_cibles, } except Exception as e: - logger.error(f"❌ Erreur SQL vérification transformation: {e}") + logger.error(f"[VERIF] Erreur vérification: {e}") return {"deja_transforme": False, "documents_cibles": []} + def peut_etre_transforme(self, numero_source, type_source, type_cible): + """Version corrigée avec normalisation""" + # NORMALISER les types + type_source = self._normaliser_type_document(type_source) + type_cible = self._normaliser_type_document(type_cible) + + logger.info( + f"[VERIF_TRANSFO] {numero_source} " + f"(type {type_source}) → type {type_cible}" + ) + + verif = self.verifier_si_deja_transforme_sql(numero_source, type_source) + + # Comparer avec le type NORMALISÉ + docs_meme_type = [ + d for d in verif["documents_cibles"] if d["type"] == type_cible + ] + + if docs_meme_type: + nums = [d["numero"] for d in docs_meme_type] + return { + "possible": False, + "raison": f"Document déjà transformé en {self._get_type_libelle(type_cible)}", + "documents_existants": docs_meme_type, + "message_detaille": f"Document(s) existant(s): {', '.join(nums)}", + } + + return { + "possible": True, + "raison": "Transformation possible", + "documents_existants": [], + } + + def obtenir_chaine_transformation_complete(self, numero_document, type_document): + """ + Obtient toute la chaîne de transformation d'un document (ascendante et descendante). + + Exemple: Pour une commande BC00001 + - Ascendant: Devis DE00123 + - Descendant: BL BL00045, Facture FA00067 + + Returns: + dict: { + "document_actuel": {...}, + "origine": {...}, # Document source (peut être None) + "descendants": [...], # Documents créés à partir de celui-ci + "chaine_complete": [...] # Toute la chaîne du devis à la facture + } + """ + logger.info( + f"[CHAINE] Analyse chaîne pour {numero_document} (type {type_document})" + ) + + try: + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + # ======================================== + # 1. Infos du document actuel + # ======================================== + cursor.execute( + """ + SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (numero_document, type_document), + ) + doc_actuel_row = cursor.fetchone() + + if not doc_actuel_row: + raise ValueError( + f"Document {numero_document} (type {type_document}) introuvable" + ) + + doc_actuel = { + "numero": doc_actuel_row.DO_Piece.strip(), + "type": int(doc_actuel_row.DO_Type), + "type_libelle": self._get_type_libelle(int(doc_actuel_row.DO_Type)), + "ref": ( + doc_actuel_row.DO_Ref.strip() if doc_actuel_row.DO_Ref else "" + ), + "date": doc_actuel_row.DO_Date, + "total_ht": ( + float(doc_actuel_row.DO_TotalHT) + if doc_actuel_row.DO_TotalHT + else 0.0 + ), + "statut": ( + int(doc_actuel_row.DO_Statut) if doc_actuel_row.DO_Statut else 0 + ), + } + + # ======================================== + # 2. Chercher le document source (ascendant) + # ======================================== + origine = None + + # Chercher dans les lignes du document actuel + cursor.execute( + """ + SELECT DISTINCT + DL_PieceDE, DL_DateDE, + DL_PieceBC, DL_DateBC, + DL_PieceBL, DL_DateBL + FROM F_DOCLIGNE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (numero_document, type_document), + ) + lignes = cursor.fetchall() + + for ligne in lignes: + # Vérifier chaque champ de liaison possible + if ligne.DL_PieceDE and ligne.DL_PieceDE.strip(): + piece_source = ligne.DL_PieceDE.strip() + type_source = 0 # Devis + break + elif ligne.DL_PieceBC and ligne.DL_PieceBC.strip(): + piece_source = ligne.DL_PieceBC.strip() + type_source = 1 # Commande + break + elif ligne.DL_PieceBL and ligne.DL_PieceBL.strip(): + piece_source = ligne.DL_PieceBL.strip() + type_source = 3 # BL + break + else: + piece_source = None + + if piece_source: + # Récupérer les infos du document source + cursor.execute( + """ + SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (piece_source, type_source), + ) + source_row = cursor.fetchone() + + if source_row: + origine = { + "numero": source_row.DO_Piece.strip(), + "type": int(source_row.DO_Type), + "type_libelle": self._get_type_libelle( + int(source_row.DO_Type) + ), + "ref": ( + source_row.DO_Ref.strip() if source_row.DO_Ref else "" + ), + "date": source_row.DO_Date, + "total_ht": ( + float(source_row.DO_TotalHT) + if source_row.DO_TotalHT + else 0.0 + ), + "statut": ( + int(source_row.DO_Statut) if source_row.DO_Statut else 0 + ), + } + logger.info( + f"[CHAINE] Origine trouvée: {origine['numero']} ({origine['type_libelle']})" + ) + + # ======================================== + # 3. Chercher les documents descendants + # ======================================== + verif = self.verifier_si_deja_transforme_sql( + numero_document, type_document + ) + descendants = verif["documents_cibles"] + + # Enrichir avec les détails + for desc in descendants: + cursor.execute( + """ + SELECT DO_Ref, DO_Date, DO_TotalHT + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (desc["numero"], desc["type"]), + ) + desc_row = cursor.fetchone() + if desc_row: + desc["ref"] = desc_row.DO_Ref.strip() if desc_row.DO_Ref else "" + desc["date"] = desc_row.DO_Date + desc["total_ht"] = ( + float(desc_row.DO_TotalHT) if desc_row.DO_TotalHT else 0.0 + ) + + # ======================================== + # 4. Construire la chaîne complète + # ======================================== + chaine_complete = [] + + # Remonter récursivement jusqu'au devis + doc_temp = origine + while doc_temp: + chaine_complete.insert(0, doc_temp) + # Chercher l'origine de ce document + verif_temp = self.verifier_si_deja_transforme_sql( + doc_temp["numero"], doc_temp["type"] + ) + # Remonter (chercher dans les lignes) + cursor.execute( + """ + SELECT DISTINCT DL_PieceDE, DL_PieceBC, DL_PieceBL + FROM F_DOCLIGNE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (doc_temp["numero"], doc_temp["type"]), + ) + ligne_temp = cursor.fetchone() + + if ligne_temp: + if ligne_temp.DL_PieceDE and ligne_temp.DL_PieceDE.strip(): + piece_parent = ligne_temp.DL_PieceDE.strip() + type_parent = 0 + elif ligne_temp.DL_PieceBC and ligne_temp.DL_PieceBC.strip(): + piece_parent = ligne_temp.DL_PieceBC.strip() + type_parent = 10 + elif ligne_temp.DL_PieceBL and ligne_temp.DL_PieceBL.strip(): + piece_parent = ligne_temp.DL_PieceBL.strip() + type_parent = 30 + else: + break + + # Récupérer infos parent + cursor.execute( + """ + SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (piece_parent, type_parent), + ) + parent_row = cursor.fetchone() + + if parent_row: + doc_temp = { + "numero": parent_row.DO_Piece.strip(), + "type": int(parent_row.DO_Type), + "type_libelle": self._get_type_libelle( + int(parent_row.DO_Type) + ), + "ref": ( + parent_row.DO_Ref.strip() + if parent_row.DO_Ref + else "" + ), + "date": parent_row.DO_Date, + "total_ht": ( + float(parent_row.DO_TotalHT) + if parent_row.DO_TotalHT + else 0.0 + ), + "statut": ( + int(parent_row.DO_Statut) + if parent_row.DO_Statut + else 0 + ), + } + else: + break + else: + break + + # Ajouter le document actuel + chaine_complete.append(doc_actuel) + + # Ajouter les descendants récursivement + def ajouter_descendants(doc, profondeur=0): + if profondeur > 10: # Sécurité contre boucles infinies + return + verif = self.verifier_si_deja_transforme_sql( + doc["numero"], doc["type"] + ) + for desc in verif["documents_cibles"]: + # Récupérer infos complètes + cursor.execute( + """ + SELECT DO_Piece, DO_Type, DO_Ref, DO_Date, DO_TotalHT, DO_Statut + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = ? + """, + (desc["numero"], desc["type"]), + ) + desc_row = cursor.fetchone() + if desc_row: + desc_complet = { + "numero": desc_row.DO_Piece.strip(), + "type": int(desc_row.DO_Type), + "type_libelle": self._get_type_libelle( + int(desc_row.DO_Type) + ), + "ref": ( + desc_row.DO_Ref.strip() if desc_row.DO_Ref else "" + ), + "date": desc_row.DO_Date, + "total_ht": ( + float(desc_row.DO_TotalHT) + if desc_row.DO_TotalHT + else 0.0 + ), + "statut": ( + int(desc_row.DO_Statut) if desc_row.DO_Statut else 0 + ), + } + if desc_complet not in chaine_complete: + chaine_complete.append(desc_complet) + ajouter_descendants(desc_complet, profondeur + 1) + + ajouter_descendants(doc_actuel) + + # ======================================== + # Résultat + # ======================================== + logger.info( + f"[CHAINE] Chaîne complète: {len(chaine_complete)} document(s)" + ) + for i, doc in enumerate(chaine_complete): + logger.info( + f"[CHAINE] {i+1}. {doc['numero']} ({doc['type_libelle']}) - " + f"{doc['total_ht']}€ HT" + ) + + return { + "document_actuel": doc_actuel, + "origine": origine, + "descendants": descendants, + "chaine_complete": chaine_complete, + } + + except Exception as e: + logger.error(f"[CHAINE] Erreur analyse chaîne: {e}", exc_info=True) + return { + "document_actuel": None, + "origine": None, + "descendants": [], + "chaine_complete": [], + } + def _get_type_libelle(self, type_doc: int) -> str: - """Retourne le libellé d'un type de document""" - types = { + """ + Retourne le libellé d'un type de document. + Gère les 2 formats : valeur réelle Sage (0,10,20...) ET valeur affichée (0,1,2...) + """ + # Mapping principal (valeurs Sage officielles) + types_officiels = { 0: "Devis", 10: "Bon de commande", 20: "Préparation", @@ -3002,11 +4178,74 @@ class SageConnector: 50: "Bon d'avoir", 60: "Facture", } - return types.get(type_doc, f"Type {type_doc}") + + # Mapping alternatif (parfois Sage stocke 1 au lieu de 10, 2 au lieu de 20, etc.) + types_alternatifs = { + 1: "Bon de commande", + 2: "Préparation", + 3: "Bon de livraison", + 4: "Bon de retour", + 5: "Bon d'avoir", + 6: "Facture", + } + + # Essayer d'abord le mapping officiel + if type_doc in types_officiels: + return types_officiels[type_doc] + + # Puis le mapping alternatif + if type_doc in types_alternatifs: + return types_alternatifs[type_doc] + + return f"Type {type_doc}" + + def _normaliser_type_document(self, type_doc: int) -> int: + """ + Normalise le type de document vers la valeur officielle Sage. + Convertit 1→10, 2→20, etc. si nécessaire + """ + # Si c'est déjà un type officiel, le retourner tel quel + + logger.info(f"[INFO] TYPE RECU{type_doc}") + + if type_doc in [0, 10, 20, 30, 40, 50, 60]: + return type_doc + + # Sinon, essayer de convertir + mapping_normalisation = { + 1: 10, # Commande + 2: 20, # Préparation + 3: 30, # BL + 4: 40, # Retour + 5: 50, # Avoir + 6: 60, # Facture + } + + return mapping_normalisation.get(type_doc, type_doc) def transformer_document( - self, numero_source, type_source, type_cible, ignorer_controle_stock=False + self, + numero_source, + type_source, + type_cible, + ignorer_controle_stock=False, + conserver_document_source=True, + verifier_doublons=True, ): + """ + Transforme un document Sage en utilisant UNIQUEMENT l'API COM/BOI officielle. + + Args: + numero_source: Numéro du document source (ex: "DE00119") + type_source: Type COM du document source (0, 10, 30...) + type_cible: Type COM du document cible (10, 30, 60...) + ignorer_controle_stock: Non utilisé (géré par Sage) + conserver_document_source: Si True, tente de conserver le document source + verifier_doublons: Si True, vérifie les doublons avant transformation + + Returns: + dict: Informations sur la transformation réussie + """ if not self.cial: raise RuntimeError("Connexion Sage non établie") @@ -3014,354 +4253,153 @@ class SageConnector: type_cible = int(type_cible) logger.info( - f"[TRANSFORM] {numero_source} (type {type_source}) → type {type_cible}" + f"[TRANSFORM] 🔄 Transformation: {numero_source} ({type_source}) → type {type_cible}" ) + # ======================================== + # VALIDATION DES TYPES + # ======================================== transformations_valides = { - (0, 10), # Devis → Commande - (0, 60), # Devis → Facture - (10, 30), # Commande → Bon de livraison - (10, 60), # Commande → Facture - (30, 60), # Bon de livraison → Facture + (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: {type_source} -> {type_cible}" + f"Transformation non autorisée: " + f"{self._get_type_libelle(type_source)} → {self._get_type_libelle(type_cible)}" ) + module, methode = transformations_valides[(type_source, type_cible)] + logger.info(f"[TRANSFORM] Méthode: Transformation.{module}.{methode}()") + # ======================================== - # FONCTION UTILITAIRE + # VÉRIFICATION OPTIONNELLE DES DOUBLONS # ======================================== - def lire_erreurs_sage(obj, nom_obj=""): - """Lit toutes les erreurs d'un objet Sage COM""" - erreurs = [] - try: - if not hasattr(obj, "Errors") or obj.Errors is None: - return erreurs + if verifier_doublons: + logger.info("[TRANSFORM] 🔍 Vérification des doublons...") + verif = self.peut_etre_transforme(numero_source, type_source, type_cible) - nb_erreurs = 0 - try: - nb_erreurs = obj.Errors.Count - except: - return erreurs - - if nb_erreurs == 0: - return erreurs - - for i in range(1, nb_erreurs + 1): - try: - err = None - try: - err = obj.Errors.Item(i) - except: - try: - err = obj.Errors(i) - except: - try: - err = obj.Errors.Item(i - 1) - except: - pass - - if err is not None: - description = "" - field = "" - number = "" - - for attr in ["Description", "Descr", "Message", "Text"]: - try: - val = getattr(err, attr, None) - if val: - description = str(val) - break - except: - pass - - for attr in ["Field", "FieldName", "Champ", "Property"]: - try: - val = getattr(err, attr, None) - if val: - field = str(val) - break - except: - pass - - for attr in ["Number", "Code", "ErrorCode", "Numero"]: - try: - val = getattr(err, attr, None) - if val is not None: - number = str(val) - break - except: - pass - - if description or field or number: - erreurs.append( - { - "source": nom_obj, - "index": i, - "description": description or "Erreur inconnue", - "field": field or "?", - "number": number or "?", - } - ) - except Exception as e: - logger.debug(f"Erreur lecture erreur {i}: {e}") - continue - except Exception as e: - logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}") - - return erreurs - - # Vérification doublons - logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...") - verification = self.verifier_si_deja_transforme_sql(numero_source, type_source) - - if verification["deja_transforme"]: - docs_existants = verification["documents_cibles"] - docs_meme_type = [d for d in docs_existants if d["type"] == type_cible] - - if docs_meme_type: - nums = [d["numero"] for d in docs_meme_type] - error_msg = ( - f"❌ Le document {numero_source} a déjà été transformé " - f"en {self._get_type_libelle(type_cible)}. " - f"Document(s) existant(s) : {', '.join(nums)}" + 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.error(f"[TRANSFORM] {error_msg}") - raise ValueError(error_msg) + + logger.info("[TRANSFORM] ✅ Aucun doublon détecté") try: with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + # ======================================== # ÉTAPE 1 : LIRE LE DOCUMENT SOURCE # ======================================== - factory = self.cial.FactoryDocumentVente - persist_source = factory.ReadPiece(type_source, numero_source) + 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: - persist_source = self._find_document_in_list( - numero_source, type_source - ) - if not persist_source: - raise ValueError( - f"Document {numero_source} (type {type_source}) introuvable" - ) + raise ValueError(f"Impossible de lire {numero_source}") doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3") doc_source.Read() - statut_actuel = getattr(doc_source, "DO_Statut", 0) - logger.info( - f"[TRANSFORM] Source: type={type_source}, statut={statut_actuel}" - ) - - # ======================================== - # ÉTAPE 2 : EXTRAIRE DONNÉES SOURCE - # ======================================== - logger.info("[TRANSFORM] Extraction données source...") - - # Client - client_code = "" - client_obj_source = None - try: - client_obj_source = getattr(doc_source, "Client", None) - if client_obj_source: - client_obj_source.Read() - client_code = getattr(client_obj_source, "CT_Num", "").strip() - except Exception as e: - logger.error(f"Erreur lecture client source: {e}") - raise ValueError("Impossible de lire le client du document source") - - if not client_code: - raise ValueError("Client introuvable dans document source") - - logger.info(f"[TRANSFORM] Client: {client_code}") - - # Date et référence - date_source = getattr(doc_source, "DO_Date", None) - reference_pour_cible = numero_source - logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}") - - # Champs à copier - champs_source = {} - champs_a_copier = [ - "DO_Souche", - "DO_Regime", - "DO_CodeJournal", - "DO_Coord01", - "DO_TypeCalcul", - "DO_Devise", - "DO_Cours", - "DO_Period", - "DO_Expedit", - "DO_NbFacture", - "DO_BLFact", - "DO_TxEsworte", - "DO_Reliquat", - "DO_Imprim", - "DO_Ventile", - "DO_Motif", - ] - - for champ in champs_a_copier: - try: - val = getattr(doc_source, champ, None) - if val is not None: - champs_source[champ] = val - except: - pass - - # Infos règlement client - client_mode_regl = None - client_cond_regl = None - - if client_obj_source: - try: - client_mode_regl = getattr( - client_obj_source, "CT_ModeRegl", None - ) - if client_mode_regl: - logger.info( - f"[TRANSFORM] Mode règlement client: {client_mode_regl}" - ) - except: - pass - - try: - client_cond_regl = getattr( - client_obj_source, "CT_CondRegl", None - ) - if client_cond_regl: - logger.info( - f"[TRANSFORM] Conditions règlement client: {client_cond_regl}" - ) - except: - pass - - # ======================================== - # ÉTAPE 3 : EXTRACTION LIGNES - # ======================================== - lignes_source = [] - factory_article = self.cial.FactoryArticle + # Informations du document source + statut_source = getattr(doc_source, "DO_Statut", 0) + nb_lignes_source = 0 try: - factory_lignes_source = getattr( - doc_source, "FactoryDocumentLigne", None - ) - if not factory_lignes_source: - factory_lignes_source = getattr( - doc_source, "FactoryDocumentVenteLigne", None - ) + 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: + pass - if factory_lignes_source: - index = 1 - while index <= 1000: - try: - ligne_p = factory_lignes_source.List(index) - if ligne_p is None: - break - - ligne = win32com.client.CastTo( - ligne_p, "IBODocumentLigne3" - ) - ligne.Read() - - # Référence article - article_ref = "" - try: - article_ref = getattr(ligne, "AR_Ref", "").strip() - if not article_ref: - article_obj = getattr(ligne, "Article", None) - if article_obj: - article_obj.Read() - article_ref = getattr( - article_obj, "AR_Ref", "" - ).strip() - except: - pass - - # Prix unitaire - prix_unitaire = float( - getattr(ligne, "DL_PrixUnitaire", 0.0) - ) - - # Si prix = 0, récupérer depuis l'article - if prix_unitaire == 0 and article_ref: - try: - persist_article = factory_article.ReadReference( - article_ref - ) - if persist_article: - article_obj_price = win32com.client.CastTo( - persist_article, "IBOArticle3" - ) - article_obj_price.Read() - prix_unitaire = float( - getattr( - article_obj_price, "AR_PrixVen", 0.0 - ) - ) - logger.info( - f" Prix récupéré depuis article {article_ref}: {prix_unitaire}€" - ) - except: - pass - - # ✅ NOUVEAU : Récupérer le DL_MvtStock de la ligne source - mvt_stock_source = 0 - try: - mvt_stock_source = int( - getattr(ligne, "DL_MvtStock", 0) - ) - except: - pass - - lignes_source.append( - { - "article_ref": article_ref, - "designation": getattr(ligne, "DL_Design", ""), - "quantite": float( - getattr(ligne, "DL_Qte", 0.0) - ), - "prix_unitaire": prix_unitaire, - "remise": float( - getattr(ligne, "DL_Remise01REM_Valeur", 0.0) - ), - "type_remise": int( - getattr(ligne, "DL_Remise01REM_Type", 0) - ), - "montant_ht": float( - getattr(ligne, "DL_MontantHT", 0.0) - ), - "mvt_stock": mvt_stock_source, # ✅ Conservé ! - } - ) - - index += 1 - except Exception as e: - logger.debug(f"Erreur ligne {index}: {e}") - break - - except Exception as e: - logger.error(f"Erreur extraction lignes: {e}") - raise ValueError( - "Impossible d'extraire les lignes du document source" - ) - - nb_lignes = len(lignes_source) - logger.info(f"[TRANSFORM] {nb_lignes} lignes extraites") - - if nb_lignes == 0: - raise ValueError("Document source vide (aucune ligne)") - - total_attendu_ht = sum(l["montant_ht"] for l in lignes_source) logger.info( - f"[TRANSFORM] Total HT attendu (calculé): {total_attendu_ht}€" + 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)") + # ======================================== - # ÉTAPE 4 : TRANSACTION + # ÉTAPE 2 : CRÉER LE TRANSFORMER + # ======================================== + 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éé") + + # ======================================== + # ÉTAPE 3 : CONFIGURATION + # ======================================== + logger.info("[TRANSFORM] ⚙️ Configuration...") + + # Tenter de définir ConserveDocuments + 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}" + ) + + # ======================================== + # ÉTAPE 4 : AJOUTER LE DOCUMENT + # ======================================== + 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}") + + # ======================================== + # ÉTAPE 5 : VÉRIFIER CANPROCESS + # ======================================== + try: + can_process = getattr(transformer, "CanProcess", False) + logger.info(f"[TRANSFORM] CanProcess: {can_process}") + except: + can_process = True + + if not can_process: + erreurs = self.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)") + + # ======================================== + # ÉTAPE 6 : TRANSACTION (optionnelle) # ======================================== transaction_active = False try: @@ -3369,416 +4407,135 @@ class SageConnector: transaction_active = True logger.debug("[TRANSFORM] Transaction démarrée") except: - logger.debug("[TRANSFORM] BeginTrans non disponible") + pass try: # ======================================== - # ÉTAPE 5 : CRÉER DOCUMENT CIBLE + # ÉTAPE 7 : PROCESS (TRANSFORMATION) # ======================================== - logger.info(f"[TRANSFORM] Création document type {type_cible}...") - - process = self.cial.CreateProcess_Document(type_cible) - if not process: - raise RuntimeError( - f"CreateProcess_Document({type_cible}) a retourné None" - ) - - doc_cible = process.Document - try: - doc_cible = win32com.client.CastTo( - doc_cible, "IBODocumentVente3" - ) - except: - pass - - logger.info("[TRANSFORM] Document cible créé") - - # ======================================== - # ÉTAPE 6 : DÉFINIR LA DATE - # ======================================== - import pywintypes - - if date_source: - try: - doc_cible.DO_Date = date_source - logger.info(f"[TRANSFORM] Date copiée: {date_source}") - except Exception as e: - logger.warning(f"Impossible de copier date: {e}") - doc_cible.DO_Date = pywintypes.Time(datetime.now()) - else: - doc_cible.DO_Date = pywintypes.Time(datetime.now()) - - # ======================================== - # ÉTAPE 7 : ASSOCIER LE CLIENT - # ======================================== - logger.info(f"[TRANSFORM] Association client {client_code}...") - - factory_client = self.cial.CptaApplication.FactoryClient - persist_client = factory_client.ReadNumero(client_code) - - if not persist_client: - raise ValueError(f"Client {client_code} introuvable") - - client_obj_cible = self._cast_client(persist_client) - if not client_obj_cible: - raise ValueError(f"Impossible de charger client {client_code}") + logger.info("[TRANSFORM] ⚙️ Process()...") try: - doc_cible.SetDefaultClient(client_obj_cible) - logger.info("[TRANSFORM] SetDefaultClient() appelé") + transformer.Process() + logger.info("[TRANSFORM] ✅ Process() réussi") except Exception as e: + logger.error(f"[TRANSFORM] ❌ Erreur Process(): {e}") + + erreurs = self.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}") + + # ======================================== + # ÉTAPE 8 : RÉCUPÉRER LES RÉSULTATS + # ======================================== + 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_cible.Client = client_obj_cible - logger.info( - "[TRANSFORM] Client assigné via propriété .Client" - ) - except Exception as e2: - raise ValueError(f"Impossible d'associer le client: {e2}") + doc_result = list_results.Item(index) + if doc_result is None: + break - # DO_Ref AVANT 1er Write - try: - doc_cible.DO_Ref = reference_pour_cible - logger.info( - f"[TRANSFORM] DO_Ref défini: {reference_pour_cible}" - ) - except Exception as e: - logger.warning(f"Impossible de définir DO_Ref: {e}") - - # ======================================== - # ÉTAPE 7.5 : COPIER CHAMPS - # ======================================== - for champ, valeur in champs_source.items(): - try: - setattr(doc_cible, champ, valeur) - logger.debug(f"[TRANSFORM] {champ} copié: {valeur}") - except Exception as e: - logger.debug(f"[TRANSFORM] {champ} non copié: {e}") - - # ======================================== - # ÉTAPE 7.6 : CHAMPS SPÉCIFIQUES FACTURES - # ======================================== - if type_cible == 60: - logger.info("[TRANSFORM] Configuration champs factures...") - - # DO_Souche - try: - souche = champs_source.get("DO_Souche", 0) - doc_cible.DO_Souche = souche - logger.debug(f" ✅ DO_Souche: {souche}") - except Exception as e: - logger.debug(f" ⚠️ DO_Souche: {e}") - - # DO_Regime - try: - regime = champs_source.get("DO_Regime", 0) - doc_cible.DO_Regime = regime - logger.debug(f" ✅ DO_Regime: {regime}") - except Exception as e: - logger.debug(f" ⚠️ DO_Regime: {e}") - - # DO_Transaction - try: - doc_cible.DO_Transaction = 11 - logger.debug(f" ✅ DO_Transaction: 11") - except Exception as e: - logger.debug(f" ⚠️ DO_Transaction: {e}") - - # Mode règlement - if client_mode_regl: - try: - doc_cible.DO_ModeRegl = client_mode_regl - logger.info(f" ✅ DO_ModeRegl: {client_mode_regl}") - except Exception as e: - logger.debug(f" ⚠️ DO_ModeRegl: {e}") - - # Conditions règlement - if client_cond_regl: - try: - doc_cible.DO_CondRegl = client_cond_regl - logger.info(f" ✅ DO_CondRegl: {client_cond_regl}") - except Exception as e: - logger.debug(f" ⚠️ DO_CondRegl: {e}") - - # 1er Write - doc_cible.Write() - logger.info("[TRANSFORM] Document initialisé (1er Write)") - - # ======================================== - # ÉTAPE 8 : COPIER LIGNES (AVEC GESTION STOCK) - # ======================================== - logger.info(f"[TRANSFORM] Copie de {nb_lignes} lignes...") - - if ignorer_controle_stock: - logger.info("[TRANSFORM] ⚠️ Contrôle de stock DÉSACTIVÉ") - - try: - factory_lignes_cible = doc_cible.FactoryDocumentLigne - except: - factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne - - lignes_creees = 0 - - for idx, ligne_data in enumerate(lignes_source, 1): - logger.debug(f"[TRANSFORM] Ligne {idx}/{nb_lignes}") - - article_ref = ligne_data["article_ref"] - if not article_ref: - logger.warning( - f"Ligne {idx}: pas de référence article, skip" - ) - continue - - persist_article = factory_article.ReadReference(article_ref) - if not persist_article: - logger.warning( - f"Ligne {idx}: article {article_ref} introuvable, skip" - ) - continue - - article_obj = win32com.client.CastTo( - persist_article, "IBOArticle3" - ) - article_obj.Read() - - ligne_persist = factory_lignes_cible.Create() - try: - ligne_obj = win32com.client.CastTo( - ligne_persist, "IBODocumentLigne3" - ) - except: - ligne_obj = win32com.client.CastTo( - ligne_persist, "IBODocumentVenteLigne3" - ) - - quantite = ligne_data["quantite"] - prix = ligne_data["prix_unitaire"] - - # ✅ CRITIQUE : Associer l'article AVANT de définir les flags stock - try: - ligne_obj.SetDefaultArticleReference(article_ref, quantite) - logger.debug(f" SetDefaultArticleReference OK") - except Exception as e1: - logger.debug(f" SetDefaultArticleReference échoué: {e1}") - try: - ligne_obj.SetDefaultArticle(article_obj, quantite) - logger.debug(f" SetDefaultArticle OK") - except Exception as e2: - logger.debug(f" SetDefaultArticle échoué: {e2}") - ligne_obj.DL_Design = ligne_data["designation"] - ligne_obj.DL_Qte = quantite - logger.debug(f" Configuration manuelle") - - # ✅ APRÈS association : Désactiver le contrôle de stock - if ignorer_controle_stock: - # Méthode 1 : Copier DL_MvtStock depuis la source - try: - mvt_stock_source = ligne_data.get("mvt_stock", 0) - ligne_obj.DL_MvtStock = mvt_stock_source - logger.debug( - f" ✅ DL_MvtStock = {mvt_stock_source} (copié depuis source)" - ) - except Exception as e: - logger.debug(f" ⚠️ DL_MvtStock: {e}") - - # Méthode 2 : DL_NoStock = 1 - try: - ligne_obj.DL_NoStock = 1 - logger.debug(f" ✅ DL_NoStock = 1") - except Exception as e: - logger.debug(f" ⚠️ DL_NoStock: {e}") - - # ✅ MÉTHODE 3 CRITIQUE : DL_NonLivre = quantité - # Indique que la quantité n'est PAS livrée, donc pas de sortie de stock - try: - ligne_obj.DL_NonLivre = quantite - logger.debug( - f" ✅ DL_NonLivre = {quantite} (évite sortie stock)" - ) - except Exception as e: - logger.debug(f" ⚠️ DL_NonLivre: {e}") - - # Prix unitaire - if prix > 0: - ligne_obj.DL_PrixUnitaire = float(prix) - logger.debug(f" Prix forcé: {prix}€") - - # Remise - remise = ligne_data["remise"] - if remise > 0: - try: - ligne_obj.DL_Remise01REM_Valeur = float(remise) - ligne_obj.DL_Remise01REM_Type = ligne_data[ - "type_remise" - ] - logger.debug(f" Remise: {remise}%") - except: - pass - - # Écrire la ligne - try: - ligne_obj.Write() - lignes_creees += 1 - logger.debug(f" ✅ Ligne {idx} écrite") - except Exception as e: - logger.error(f" ❌ Erreur écriture ligne {idx}: {e}") - - # Lire erreurs - erreurs_ligne = lire_erreurs_sage(ligne_obj, f"Ligne_{idx}") - for err in erreurs_ligne: - logger.error( - f" {err['field']}: {err['description']}" - ) - - continue - - logger.info(f"[TRANSFORM] {lignes_creees} lignes créées") - - if lignes_creees == 0: - raise ValueError("Aucune ligne n'a pu être créée") - - # ======================================== - # ÉTAPE 9 : WRITE FINAL - # ======================================== - logger.info("[TRANSFORM] Write() final avant Process()...") - doc_cible.Write() - - # ======================================== - # ÉTAPE 10 : PROCESS() - # ======================================== - logger.info("[TRANSFORM] Appel Process()...") - - try: - process.Process() - logger.info("[TRANSFORM] Process() réussi !") - - except Exception as e: - logger.error(f"[TRANSFORM] ERREUR Process(): {e}") - - toutes_erreurs = [] - - # Erreurs du process - erreurs_process = lire_erreurs_sage(process, "Process") - if erreurs_process: - logger.error( - f"[TRANSFORM] {len(erreurs_process)} erreur(s) process:" - ) - for err in erreurs_process: - logger.error( - f" {err['field']}: {err['description']} (code: {err['number']})" - ) - toutes_erreurs.append( - f"{err['field']}: {err['description']}" - ) - - # Erreurs du document - erreurs_doc = lire_erreurs_sage(doc_cible, "Document") - if erreurs_doc: - logger.error( - f"[TRANSFORM] {len(erreurs_doc)} erreur(s) document:" - ) - for err in erreurs_doc: - logger.error( - f" {err['field']}: {err['description']} (code: {err['number']})" - ) - toutes_erreurs.append( - f"{err['field']}: {err['description']}" - ) - - # Construire message - if toutes_erreurs: - error_msg = ( - f"Process() échoué: {' | '.join(toutes_erreurs)}" - ) - else: - error_msg = f"Process() échoué: {str(e)}" - - # Conseil stock - if "stock" in error_msg.lower() or "2881" in error_msg: - error_msg += " | CONSEIL: Vérifiez le stock ou créez le document manuellement dans Sage." - - logger.error(f"[TRANSFORM] {error_msg}") - raise RuntimeError(error_msg) - - # ======================================== - # ÉTAPE 11 : RÉCUPÉRER NUMÉRO - # ======================================== - numero_cible = None - total_ht_final = 0.0 - total_ttc_final = 0.0 - - # DocumentResult - try: - doc_result = process.DocumentResult - if doc_result: doc_result = win32com.client.CastTo( doc_result, "IBODocumentVente3" ) doc_result.Read() - numero_cible = getattr(doc_result, "DO_Piece", "") - total_ht_final = float( - getattr(doc_result, "DO_TotalHT", 0.0) - ) - total_ttc_final = float( - getattr(doc_result, "DO_TotalTTC", 0.0) + + 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)) + + # Compter les lignes via COM + 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: + pass + + documents_crees.append( + { + "numero": numero_cible, + "total_ht": total_ht, + "total_ttc": total_ttc, + "nb_lignes": nb_lignes, + } ) + logger.info( - f"[TRANSFORM] DocumentResult: {numero_cible}, {total_ht_final}€ HT" + f"[TRANSFORM] Document créé: {numero_cible} " + f"({nb_lignes} lignes, {total_ht}€ HT)" ) - except Exception as e: - logger.debug(f"[TRANSFORM] DocumentResult non disponible: {e}") - # Relire doc_cible - if not numero_cible: - try: - doc_cible.Read() - numero_cible = getattr(doc_cible, "DO_Piece", "") - total_ht_final = float( - getattr(doc_cible, "DO_TotalHT", 0.0) - ) - total_ttc_final = float( - getattr(doc_cible, "DO_TotalTTC", 0.0) - ) - logger.info( - f"[TRANSFORM] doc_cible relu: {numero_cible}, {total_ht_final}€ HT" - ) - except: - pass + index += 1 - if not numero_cible: - raise RuntimeError("Numéro document cible vide après Process()") + except Exception as e: + logger.debug(f"Fin de liste à index {index}") + break - logger.info(f"[TRANSFORM] Document cible créé: {numero_cible}") - logger.info( - f"[TRANSFORM] Totaux: {total_ht_final}€ HT / {total_ttc_final}€ TTC" - ) + if not documents_crees: + raise RuntimeError("Aucun document créé après Process()") # ======================================== - # ÉTAPE 12 : COMMIT + # ÉTAPE 9 : COMMIT # ======================================== if transaction_active: try: self.cial.CptaApplication.CommitTrans() - logger.info("[TRANSFORM] Transaction committée") + logger.debug("[TRANSFORM] Transaction committée") except: pass - time.sleep(1) + # Pause de sécurité + time.sleep(1.5) + + # ======================================== + # RÉSULTAT + # ======================================== + doc_principal = documents_crees[0] logger.info( - f"[TRANSFORM] ✅ SUCCÈS: {numero_source} ({type_source}) -> " - f"{numero_cible} ({type_cible}) - {lignes_creees} lignes" + 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": numero_cible, - "nb_lignes": lignes_creees, - "total_ht": total_ht_final, - "total_ttc": total_ttc_final, + "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 as e: + # Rollback en cas d'erreur if transaction_active: try: self.cial.CptaApplication.RollbackTrans() @@ -3787,9 +4544,109 @@ class SageConnector: pass raise + except ValueError as e: + # Erreur métier (validation, doublon, etc.) + logger.error(f"[TRANSFORM] ❌ Erreur métier: {e}") + raise + + except RuntimeError as e: + # Erreur technique Sage + logger.error(f"[TRANSFORM] ❌ Erreur technique: {e}") + raise + except Exception as e: - logger.error(f"[TRANSFORM] ❌ ERREUR: {e}", exc_info=True) - raise RuntimeError(f"Echec transformation: {str(e)}") + # Erreur inattendue + logger.error(f"[TRANSFORM] ❌ Erreur inattendue: {e}", exc_info=True) + raise RuntimeError(f"Échec transformation: {str(e)}") + + def lire_erreurs_sage(self, obj, nom_obj=""): + """ + Lit toutes les erreurs d'un objet Sage COM. + Utilisé pour diagnostiquer les échecs de Process(). + """ + erreurs = [] + try: + if not hasattr(obj, "Errors") or obj.Errors is None: + return erreurs + + nb_erreurs = 0 + try: + nb_erreurs = obj.Errors.Count + except: + return erreurs + + if nb_erreurs == 0: + return erreurs + + for i in range(1, nb_erreurs + 1): + try: + err = None + # Plusieurs façons d'accéder aux erreurs selon la version Sage + try: + err = obj.Errors.Item(i) + except: + try: + err = obj.Errors(i) + except: + try: + err = obj.Errors.Item(i - 1) + except: + pass + + if err is not None: + description = "" + field = "" + number = "" + + # Description + for attr in ["Description", "Descr", "Message", "Text"]: + try: + val = getattr(err, attr, None) + if val: + description = str(val) + break + except: + pass + + # Champ concerné + for attr in ["Field", "FieldName", "Champ", "Property"]: + try: + val = getattr(err, attr, None) + if val: + field = str(val) + break + except: + pass + + # Numéro d'erreur + for attr in ["Number", "Code", "ErrorCode", "Numero"]: + try: + val = getattr(err, attr, None) + if val is not None: + number = str(val) + break + except: + pass + + if description or field or number: + erreurs.append( + { + "source": nom_obj, + "index": i, + "description": description or "Erreur inconnue", + "field": field or "?", + "number": number or "?", + } + ) + + except Exception as e: + logger.debug(f"Erreur lecture erreur {i}: {e}") + continue + + except Exception as e: + logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}") + + return erreurs def _find_document_in_list(self, numero, type_doc): """Cherche un document dans List() si ReadPiece échoue""" @@ -7253,7 +8110,6 @@ class SageConnector: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") - def creer_article(self, article_data: dict) -> dict: with self._com_context(), self._lock_com: try: @@ -10555,11 +11411,11 @@ class SageConnector: try: with self._get_sql_connection() as conn: cursor = conn.cursor() - + # ✅ LOCK pour éviter les race conditions cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE") cursor.execute("BEGIN TRANSACTION") - + try: # Lire stock avec lock cursor.execute( @@ -10568,26 +11424,26 @@ class SageConnector: FROM F_ARTSTOCK WITH (UPDLOCK, ROWLOCK) WHERE AR_Ref = ? """, - (article_ref.upper(),) + (article_ref.upper(),), ) - + row = cursor.fetchone() stock_dispo = float(row[0]) if row and row[0] else 0.0 - + suffisant = stock_dispo >= quantite - + cursor.execute("COMMIT") - + return { "suffisant": suffisant, "stock_disponible": stock_dispo, - "quantite_demandee": quantite + "quantite_demandee": quantite, } - + except: cursor.execute("ROLLBACK") raise - + except Exception as e: logger.error(f"Erreur vérification stock: {e}") raise @@ -10595,59 +11451,64 @@ class SageConnector: def lister_modeles_crystal(self) -> Dict: """ 📋 Liste les modèles en scannant le répertoire Sage - + ✅ FONCTIONNE MÊME SI FactoryEtat N'EXISTE PAS """ try: logger.info("[MODELES] Scan du répertoire des modèles...") - + # Chemin typique des modèles Sage 100c # Adapter selon votre installation chemins_possibles = [ r"C:\Users\Public\Documents\Sage\Entreprise 100c\fr-FR\Documents standards\Gestion commerciale\Ventes", ] - + # Essayer de détecter depuis la base Sage chemin_base = self.chemin_base if chemin_base: # Extraire le répertoire Sage import os + dossier_sage = os.path.dirname(os.path.dirname(chemin_base)) chemins_possibles.insert(0, os.path.join(dossier_sage, "Modeles")) - + modeles_par_type = { "devis": [], "commandes": [], "livraisons": [], "factures": [], "avoirs": [], - "autres": [] + "autres": [], } - + # Scanner les répertoires import os import glob - + for chemin in chemins_possibles: if not os.path.exists(chemin): continue - + logger.info(f"[MODELES] Scan: {chemin}") - + # Chercher tous les fichiers .RPT et .BGC for pattern in ["*.RPT", "*.rpt", "*.BGC", "*.bgc"]: fichiers = glob.glob(os.path.join(chemin, pattern)) - + for fichier in fichiers: nom_fichier = os.path.basename(fichier) - + # Déterminer la catégorie categorie = "autres" - + nom_upper = nom_fichier.upper() if "DEVIS" in nom_upper or nom_upper.startswith("VT_DE"): categorie = "devis" - elif "CMDE" in nom_upper or "COMMANDE" in nom_upper or nom_upper.startswith("VT_BC"): + elif ( + "CMDE" in nom_upper + or "COMMANDE" in nom_upper + or nom_upper.startswith("VT_BC") + ): categorie = "commandes" elif nom_upper.startswith("VT_BL") or "LIVRAISON" in nom_upper: categorie = "livraisons" @@ -10655,31 +11516,35 @@ class SageConnector: categorie = "factures" elif "AVOIR" in nom_upper or nom_upper.startswith("VT_AV"): categorie = "avoirs" - - modeles_par_type[categorie].append({ - "fichier": nom_fichier, - "nom": nom_fichier.replace(".RPT", "").replace(".rpt", "").replace(".BGC", "").replace(".bgc", ""), - "chemin_complet": fichier - }) - + + modeles_par_type[categorie].append( + { + "fichier": nom_fichier, + "nom": nom_fichier.replace(".RPT", "") + .replace(".rpt", "") + .replace(".BGC", "") + .replace(".bgc", ""), + "chemin_complet": fichier, + } + ) + # Si on a trouvé des fichiers, pas besoin de continuer if any(len(v) > 0 for v in modeles_par_type.values()): break - + total = sum(len(v) for v in modeles_par_type.values()) logger.info(f"[MODELES] {total} modèles trouvés") - + return modeles_par_type - + except Exception as e: logger.error(f"[MODELES] Erreur: {e}", exc_info=True) raise RuntimeError(f"Erreur listage modèles: {str(e)}") - def _detecter_methodes_impression(self, doc) -> dict: """🔍 Détecte les méthodes d'impression disponibles""" methodes = {} - + # Tester FactoryEtat try: factory_etat = self.cial.CptaApplication.FactoryEtat @@ -10692,93 +11557,93 @@ class SageConnector: methodes["FactoryEtat"] = True except: pass - + # Tester Imprimer() if hasattr(doc, "Imprimer"): methodes["Imprimer"] = True - + # Tester Print() if hasattr(doc, "Print"): methodes["Print"] = True - + # Tester ExportPDF() if hasattr(doc, "ExportPDF"): methodes["ExportPDF"] = True - + return methodes - + def generer_pdf_document( - self, - numero: str, - type_doc: int, - modele: str = None + self, numero: str, type_doc: int, modele: str = None ) -> bytes: """ 📄 Génération PDF Sage 100c - UTILISE LES .BGC DIRECTEMENT - + Args: numero: Numéro document (ex: "FA00123") type_doc: Type Sage (0=devis, 60=facture, etc.) modele: Nom fichier .bgc (optionnel) - + Returns: bytes: Contenu PDF """ if not self.cial: raise RuntimeError("Connexion Sage non établie") - + try: with self._com_context(), self._lock_com: logger.info(f"[PDF] === GÉNÉRATION PDF AVEC .BGC ===") logger.info(f"[PDF] Document: {numero} (type={type_doc})") - + # ======================================== # 1. CHARGER LE DOCUMENT SAGE # ======================================== factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(type_doc, numero) - + if not persist: persist = self._find_document_in_list(numero, type_doc) - + if not persist: raise ValueError(f"Document {numero} introuvable") - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + logger.info(f"[PDF] ✅ Document chargé") - + # ======================================== # 2. DÉTERMINER LE MODÈLE .BGC # ======================================== chemin_modele = self._determiner_modele(type_doc, modele) logger.info(f"[PDF] 📄 Modèle: {os.path.basename(chemin_modele)}") logger.info(f"[PDF] 📁 Chemin: {chemin_modele}") - + # ======================================== # 3. VÉRIFIER QUE LE FICHIER EXISTE # ======================================== import os + if not os.path.exists(chemin_modele): raise ValueError(f"Modèle introuvable: {chemin_modele}") - + # ======================================== # 4. CRÉER FICHIER TEMPORAIRE # ======================================== import tempfile import time - + temp_dir = tempfile.gettempdir() - pdf_path = os.path.join(temp_dir, f"sage_{numero}_{int(time.time())}.pdf") - + pdf_path = os.path.join( + temp_dir, f"sage_{numero}_{int(time.time())}.pdf" + ) + pdf_bytes = None - + # ======================================== # MÉTHODE 1 : Crystal Reports Runtime (PRIORITAIRE) # ======================================== logger.info("[PDF] 🔷 Méthode 1: Crystal Reports Runtime...") - + try: pdf_bytes = self._generer_pdf_crystal_runtime( numero, type_doc, chemin_modele, pdf_path @@ -10787,13 +11652,13 @@ class SageConnector: logger.info("[PDF] ✅ Méthode 1 réussie (Crystal Runtime)") except Exception as e: logger.warning(f"[PDF] Méthode 1 échouée: {e}") - + # ======================================== # MÉTHODE 2 : Crystal via DLL Sage # ======================================== if not pdf_bytes: logger.info("[PDF] 🔷 Méthode 2: Crystal via DLL Sage...") - + try: pdf_bytes = self._generer_pdf_crystal_sage_dll( numero, type_doc, chemin_modele, pdf_path @@ -10802,13 +11667,13 @@ class SageConnector: logger.info("[PDF] ✅ Méthode 2 réussie (DLL Sage)") except Exception as e: logger.warning(f"[PDF] Méthode 2 échouée: {e}") - + # ======================================== # MÉTHODE 3 : Sage Reports Viewer (si installé) # ======================================== if not pdf_bytes: logger.info("[PDF] 🔷 Méthode 3: Sage Reports Viewer...") - + try: pdf_bytes = self._generer_pdf_sage_viewer( numero, type_doc, chemin_modele, pdf_path @@ -10817,21 +11682,21 @@ class SageConnector: logger.info("[PDF] ✅ Méthode 3 réussie (Sage Viewer)") except Exception as e: logger.warning(f"[PDF] Méthode 3 échouée: {e}") - + # ======================================== # MÉTHODE 4 : Python reportlab (FALLBACK) # ======================================== if not pdf_bytes: logger.warning("[PDF] ⚠️ TOUTES LES MÉTHODES .BGC ONT ÉCHOUÉ") logger.info("[PDF] 🔷 Méthode 4: PDF Custom (fallback)...") - + try: pdf_bytes = self._generer_pdf_custom(doc, numero, type_doc) if pdf_bytes: logger.info("[PDF] ✅ Méthode 4 réussie (PDF custom)") except Exception as e: logger.error(f"[PDF] Méthode 4 échouée: {e}") - + # ======================================== # VALIDATION & NETTOYAGE # ======================================== @@ -10840,7 +11705,7 @@ class SageConnector: os.remove(pdf_path) except: pass - + if not pdf_bytes: raise RuntimeError( "❌ ÉCHEC GÉNÉRATION PDF\n\n" @@ -10857,38 +11722,38 @@ class SageConnector: " services.msc → SAP Crystal Reports Processing Server\n\n" "4. En attendant, utiliser /pdf-custom pour un PDF simple" ) - + if len(pdf_bytes) < 500: raise RuntimeError("PDF généré trop petit (probablement corrompu)") - + logger.info(f"[PDF] ✅✅✅ SUCCÈS: {len(pdf_bytes):,} octets") return pdf_bytes - + except ValueError as e: logger.error(f"[PDF] Erreur métier: {e}") raise except Exception as e: logger.error(f"[PDF] Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur PDF: {str(e)}") - + def _generer_pdf_crystal_runtime(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 1: Crystal Reports Runtime API""" try: import os - + # Essayer différentes ProgID Crystal Reports prog_ids_crystal = [ - "CrystalRuntime.Application.140", # Crystal Reports 2020 - "CrystalRuntime.Application.13", # Crystal Reports 2016 - "CrystalRuntime.Application.12", # Crystal Reports 2013 - "CrystalRuntime.Application.11", # Crystal Reports 2011 - "CrystalRuntime.Application", # Générique - "CrystalDesignRunTime.Application", # Alternative + "CrystalRuntime.Application.140", # Crystal Reports 2020 + "CrystalRuntime.Application.13", # Crystal Reports 2016 + "CrystalRuntime.Application.12", # Crystal Reports 2013 + "CrystalRuntime.Application.11", # Crystal Reports 2011 + "CrystalRuntime.Application", # Générique + "CrystalDesignRunTime.Application", # Alternative ] - + crystal = None prog_id_utilisee = None - + for prog_id in prog_ids_crystal: try: crystal = win32com.client.Dispatch(prog_id) @@ -10898,73 +11763,71 @@ class SageConnector: except Exception as e: logger.debug(f" {prog_id}: {e}") continue - + if not crystal: logger.info(" ❌ Aucune ProgID Crystal trouvée") return None - + # Ouvrir le rapport .bgc logger.info(f" 📂 Ouverture: {os.path.basename(chemin_modele)}") rapport = crystal.OpenReport(chemin_modele) - + # Configurer la connexion SQL logger.info(" 🔌 Configuration connexion SQL...") - + for table in rapport.Database.Tables: try: # Méthode 1: SetDataSource - table.SetDataSource( - self.sql_server, - self.sql_database, - "", - "" - ) + table.SetDataSource(self.sql_server, self.sql_database, "", "") except: try: # Méthode 2: ConnectionProperties table.ConnectionProperties.Item["Server Name"] = self.sql_server - table.ConnectionProperties.Item["Database Name"] = self.sql_database + table.ConnectionProperties.Item["Database Name"] = ( + self.sql_database + ) table.ConnectionProperties.Item["Integrated Security"] = True except: pass - + # Appliquer le filtre Crystal Reports logger.info(f" 🔍 Filtre: DO_Piece = '{numero}'") rapport.RecordSelectionFormula = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'" - + # Exporter en PDF logger.info(" 📄 Export PDF...") - rapport.ExportOptions.DestinationType = 1 # crEDTDiskFile - rapport.ExportOptions.FormatType = 31 # crEFTPortableDocFormat (PDF) + rapport.ExportOptions.DestinationType = 1 # crEDTDiskFile + rapport.ExportOptions.FormatType = 31 # crEFTPortableDocFormat (PDF) rapport.ExportOptions.DiskFileName = pdf_path rapport.Export(False) - + # Attendre la création du fichier import time + max_wait = 30 waited = 0 - + while not os.path.exists(pdf_path) and waited < max_wait: time.sleep(0.5) waited += 0.5 - + if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: - with open(pdf_path, 'rb') as f: + with open(pdf_path, "rb") as f: return f.read() - + logger.warning(" ⚠️ Fichier PDF non créé") return None - + except Exception as e: logger.debug(f" Crystal Runtime: {e}") return None - + def _generer_pdf_crystal_sage_dll(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 2: Utiliser les DLL Crystal de Sage directement""" try: import os import ctypes - + # Chercher les DLL Crystal dans le dossier Sage dossier_sage = os.path.dirname(os.path.dirname(self.chemin_base)) chemins_dll = [ @@ -10972,57 +11835,58 @@ class SageConnector: os.path.join(dossier_sage, "Crystal", "crpe32.dll"), r"C:\Program Files (x86)\SAP BusinessObjects\Crystal Reports for .NET Framework 4.0\Common\SAP BusinessObjects Enterprise XI 4.0\win64_x64\crpe32.dll", ] - + dll_trouvee = None for chemin_dll in chemins_dll: if os.path.exists(chemin_dll): dll_trouvee = chemin_dll break - + if not dll_trouvee: logger.info(" ❌ DLL Crystal Sage non trouvée") return None - + logger.info(f" ✅ DLL trouvée: {dll_trouvee}") - + # Charger la DLL crpe = ctypes.cdll.LoadLibrary(dll_trouvee) - + # Ouvrir le rapport (API C Crystal Reports) # Note: Ceci est une approche bas niveau, peut nécessiter des ajustements job_handle = crpe.PEOpenPrintJob(chemin_modele.encode()) - + if job_handle == 0: logger.warning(" ⚠️ Impossible d'ouvrir le rapport") return None - + # Définir les paramètres de connexion # ... (code simplifié, nécessiterait plus de configuration) - + # Exporter crpe.PEExportTo(job_handle, pdf_path.encode(), 31) # 31 = PDF - + # Fermer crpe.PEClosePrintJob(job_handle) - + import time + time.sleep(2) - + if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: - with open(pdf_path, 'rb') as f: + with open(pdf_path, "rb") as f: return f.read() - + return None - + except Exception as e: logger.debug(f" DLL Sage: {e}") return None - + def _generer_pdf_sage_viewer(self, numero, type_doc, chemin_modele, pdf_path): """🔷 Méthode 3: Sage Reports Viewer (si installé)""" try: import os - + # Chercher l'exécutable Sage Reports executables_possibles = [ r"C:\Program Files\Sage\Reports\SageReports.exe", @@ -11030,51 +11894,56 @@ class SageConnector: os.path.join( os.path.dirname(os.path.dirname(self.chemin_base)), "Reports", - "SageReports.exe" - ) + "SageReports.exe", + ), ] - + exe_trouve = None for exe in executables_possibles: if os.path.exists(exe): exe_trouve = exe break - + if not exe_trouve: logger.info(" ❌ SageReports.exe non trouvé") return None - + logger.info(f" ✅ SageReports trouvé: {exe_trouve}") - + # Lancer en ligne de commande avec paramètres import subprocess - + cmd = [ exe_trouve, - "/report", chemin_modele, - "/export", pdf_path, - "/format", "PDF", - "/filter", f"DO_Piece='{numero}'", - "/silent" + "/report", + chemin_modele, + "/export", + pdf_path, + "/format", + "PDF", + "/filter", + f"DO_Piece='{numero}'", + "/silent", ] - + logger.info(" 🚀 Lancement SageReports...") result = subprocess.run(cmd, capture_output=True, timeout=30) - + import time + time.sleep(2) - + if os.path.exists(pdf_path) and os.path.getsize(pdf_path) > 0: - with open(pdf_path, 'rb') as f: + with open(pdf_path, "rb") as f: return f.read() - + logger.warning(" ⚠️ PDF non généré par SageReports") return None - + except Exception as e: logger.debug(f" Sage Viewer: {e}") return None - + def _generer_pdf_custom(self, doc, numero, type_doc): """🎨 Génère un PDF simple avec les données du document (FALLBACK)""" try: @@ -11083,170 +11952,184 @@ class SageConnector: from reportlab.pdfgen import canvas from reportlab.lib import colors from io import BytesIO - + buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - + # En-tête pdf.setFont("Helvetica-Bold", 20) - type_libelles = {0: "DEVIS", 10: "BON DE COMMANDE", 30: "BON DE LIVRAISON", - 60: "FACTURE", 50: "AVOIR"} + type_libelles = { + 0: "DEVIS", + 10: "BON DE COMMANDE", + 30: "BON DE LIVRAISON", + 60: "FACTURE", + 50: "AVOIR", + } type_libelle = type_libelles.get(type_doc, "DOCUMENT") - - pdf.drawString(2*cm, height - 3*cm, type_libelle) - + + pdf.drawString(2 * cm, height - 3 * cm, type_libelle) + # Numéro pdf.setFont("Helvetica", 12) - pdf.drawString(2*cm, height - 4*cm, f"Numéro: {numero}") - + pdf.drawString(2 * cm, height - 4 * cm, f"Numéro: {numero}") + # Date date_doc = getattr(doc, "DO_Date", "") - pdf.drawString(2*cm, height - 4.5*cm, f"Date: {date_doc}") - + pdf.drawString(2 * cm, height - 4.5 * cm, f"Date: {date_doc}") + # Client try: client = getattr(doc, "Client", None) if client: client.Read() client_nom = getattr(client, "CT_Intitule", "") - pdf.drawString(2*cm, height - 5.5*cm, f"Client: {client_nom}") + pdf.drawString(2 * cm, height - 5.5 * cm, f"Client: {client_nom}") except: pass - + # Ligne séparatrice - pdf.line(2*cm, height - 6*cm, width - 2*cm, height - 6*cm) - + pdf.line(2 * cm, height - 6 * cm, width - 2 * cm, height - 6 * cm) + # Lignes du document - y_pos = height - 7*cm + y_pos = height - 7 * cm pdf.setFont("Helvetica-Bold", 10) - pdf.drawString(2*cm, y_pos, "Article") - pdf.drawString(8*cm, y_pos, "Qté") - pdf.drawString(11*cm, y_pos, "Prix U.") - pdf.drawString(15*cm, y_pos, "Total") - - y_pos -= 0.5*cm - pdf.line(2*cm, y_pos, width - 2*cm, y_pos) - + pdf.drawString(2 * cm, y_pos, "Article") + pdf.drawString(8 * cm, y_pos, "Qté") + pdf.drawString(11 * cm, y_pos, "Prix U.") + pdf.drawString(15 * cm, y_pos, "Total") + + y_pos -= 0.5 * cm + pdf.line(2 * cm, y_pos, width - 2 * cm, y_pos) + # Lire lignes pdf.setFont("Helvetica", 9) try: factory_lignes = getattr(doc, "FactoryDocumentLigne", None) if factory_lignes: idx = 1 - while idx <= 50 and y_pos > 5*cm: + while idx <= 50 and y_pos > 5 * cm: try: ligne_p = factory_lignes.List(idx) if ligne_p is None: break - + ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3") ligne.Read() - - y_pos -= 0.7*cm - + + y_pos -= 0.7 * cm + design = getattr(ligne, "DL_Design", "")[:40] qte = float(getattr(ligne, "DL_Qte", 0)) prix = float(getattr(ligne, "DL_PrixUnitaire", 0)) total = float(getattr(ligne, "DL_MontantHT", 0)) - - pdf.drawString(2*cm, y_pos, design) - pdf.drawString(8*cm, y_pos, f"{qte:.2f}") - pdf.drawString(11*cm, y_pos, f"{prix:.2f}€") - pdf.drawString(15*cm, y_pos, f"{total:.2f}€") - + + pdf.drawString(2 * cm, y_pos, design) + pdf.drawString(8 * cm, y_pos, f"{qte:.2f}") + pdf.drawString(11 * cm, y_pos, f"{prix:.2f}€") + pdf.drawString(15 * cm, y_pos, f"{total:.2f}€") + idx += 1 except: break except: pass - + # Totaux - y_pos = 5*cm - pdf.line(2*cm, y_pos, width - 2*cm, y_pos) - - y_pos -= 0.7*cm + y_pos = 5 * cm + pdf.line(2 * cm, y_pos, width - 2 * cm, y_pos) + + y_pos -= 0.7 * cm pdf.setFont("Helvetica-Bold", 11) - + total_ht = float(getattr(doc, "DO_TotalHT", 0)) total_ttc = float(getattr(doc, "DO_TotalTTC", 0)) - - pdf.drawString(13*cm, y_pos, f"Total HT:") - pdf.drawString(16*cm, y_pos, f"{total_ht:.2f}€") - - y_pos -= 0.7*cm - pdf.drawString(13*cm, y_pos, f"TVA:") - pdf.drawString(16*cm, y_pos, f"{(total_ttc - total_ht):.2f}€") - - y_pos -= 0.7*cm + + pdf.drawString(13 * cm, y_pos, f"Total HT:") + pdf.drawString(16 * cm, y_pos, f"{total_ht:.2f}€") + + y_pos -= 0.7 * cm + pdf.drawString(13 * cm, y_pos, f"TVA:") + pdf.drawString(16 * cm, y_pos, f"{(total_ttc - total_ht):.2f}€") + + y_pos -= 0.7 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(13*cm, y_pos, f"Total TTC:") - pdf.drawString(16*cm, y_pos, f"{total_ttc:.2f}€") - + pdf.drawString(13 * cm, y_pos, f"Total TTC:") + pdf.drawString(16 * cm, y_pos, f"{total_ttc:.2f}€") + # Pied de page pdf.setFont("Helvetica", 8) - pdf.drawString(2*cm, 2*cm, "PDF généré par l'API Sage - Version simplifiée") - pdf.drawString(2*cm, 1.5*cm, "Pour un rendu Crystal Reports complet, installez SAP BusinessObjects") - + pdf.drawString( + 2 * cm, 2 * cm, "PDF généré par l'API Sage - Version simplifiée" + ) + pdf.drawString( + 2 * cm, + 1.5 * cm, + "Pour un rendu Crystal Reports complet, installez SAP BusinessObjects", + ) + pdf.showPage() pdf.save() - + return buffer.getvalue() - + except Exception as e: logger.error(f"PDF custom: {e}") - return None + return None def _determiner_modele(self, type_doc: int, modele_demande: str = None) -> str: """ 🔍 Détermine le chemin du modèle Crystal Reports à utiliser - + Args: type_doc: Type Sage (0=devis, 60=facture, etc.) modele_demande: Nom fichier .bgc spécifique (optionnel) - + Returns: str: Chemin complet du modèle """ if modele_demande: # Modèle spécifié modeles_dispo = self.lister_modeles_crystal() - + for categorie, liste in modeles_dispo.items(): for m in liste: if m["fichier"].lower() == modele_demande.lower(): return m["chemin_complet"] - + raise ValueError(f"Modèle '{modele_demande}' introuvable") - + # Modèle par défaut selon type modeles = self.lister_modeles_crystal() - + mapping = { 0: "devis", 10: "commandes", 30: "livraisons", 60: "factures", - 50: "avoirs" + 50: "avoirs", } - + categorie = mapping.get(type_doc) - + if not categorie or categorie not in modeles: raise ValueError(f"Aucun modèle disponible pour type {type_doc}") - + liste = modeles[categorie] if not liste: raise ValueError(f"Aucun modèle {categorie} trouvé") - + # Prioriser modèle "standard" (sans FlyDoc, email, etc.) modele_std = next( - (m for m in liste - if "flydoc" not in m["fichier"].lower() - and "email" not in m["fichier"].lower()), - liste[0] + ( + m + for m in liste + if "flydoc" not in m["fichier"].lower() + and "email" not in m["fichier"].lower() + ), + liste[0], ) - + return modele_std["chemin_complet"] def diagnostiquer_impression_approfondi(self): @@ -11256,12 +12139,12 @@ class SageConnector: logger.info("=" * 80) logger.info("DIAGNOSTIC IMPRESSION APPROFONDI") logger.info("=" * 80) - + objets_a_tester = [ ("self.cial", self.cial), ("CptaApplication", self.cial.CptaApplication), ] - + # Charger un document pour tester try: factory = self.cial.FactoryDocumentVente @@ -11272,58 +12155,83 @@ class SageConnector: objets_a_tester.append(("Document", doc)) except: pass - + for nom_objet, objet in objets_a_tester: logger.info(f"\n{'='*60}") logger.info(f"OBJET: {nom_objet}") logger.info(f"{'='*60}") - + # Chercher tous les attributs qui contiennent "print", "etat", "bilan", "crystal", "report" - mots_cles = ["print", "etat", "bilan", "crystal", "report", "pdf", "export", "impression", "imprimer"] - + mots_cles = [ + "print", + "etat", + "bilan", + "crystal", + "report", + "pdf", + "export", + "impression", + "imprimer", + ] + attributs_trouves = [] - + for attr in dir(objet): - if attr.startswith('_'): + if attr.startswith("_"): continue - + attr_lower = attr.lower() - + # Vérifier si contient un des mots-clés if any(mot in attr_lower for mot in mots_cles): try: val = getattr(objet, attr) type_val = type(val).__name__ is_callable = callable(val) - - attributs_trouves.append({ - "nom": attr, - "type": type_val, - "callable": is_callable - }) - - logger.info(f" ✅ TROUVÉ: {attr} ({type_val}) {'[MÉTHODE]' if is_callable else '[PROPRIÉTÉ]'}") - + + attributs_trouves.append( + { + "nom": attr, + "type": type_val, + "callable": is_callable, + } + ) + + logger.info( + f" ✅ TROUVÉ: {attr} ({type_val}) {'[MÉTHODE]' if is_callable else '[PROPRIÉTÉ]'}" + ) + except Exception as e: logger.debug(f" Erreur {attr}: {e}") - + if not attributs_trouves: - logger.warning(f" ❌ Aucun objet d'impression trouvé sur {nom_objet}") - + logger.warning( + f" ❌ Aucun objet d'impression trouvé sur {nom_objet}" + ) + # Tester des noms de méthodes spécifiques logger.info(f"\n{'='*60}") logger.info("TESTS DIRECTS") logger.info(f"{'='*60}") - + methodes_a_tester = [ ("self.cial.BilanEtat", lambda: self.cial.BilanEtat), ("self.cial.Etat", lambda: self.cial.Etat), - ("self.cial.CptaApplication.BilanEtat", lambda: self.cial.CptaApplication.BilanEtat), - ("self.cial.CptaApplication.Etat", lambda: self.cial.CptaApplication.Etat), + ( + "self.cial.CptaApplication.BilanEtat", + lambda: self.cial.CptaApplication.BilanEtat, + ), + ( + "self.cial.CptaApplication.Etat", + lambda: self.cial.CptaApplication.Etat, + ), ("self.cial.FactoryEtat", lambda: self.cial.FactoryEtat), - ("self.cial.CptaApplication.FactoryEtat", lambda: self.cial.CptaApplication.FactoryEtat), + ( + "self.cial.CptaApplication.FactoryEtat", + lambda: self.cial.CptaApplication.FactoryEtat, + ), ] - + for nom, getter in methodes_a_tester: try: obj = getter() @@ -11332,55 +12240,55 @@ class SageConnector: logger.info(f" ❌ {nom} N'EXISTE PAS : {e}") except Exception as e: logger.info(f" ⚠️ {nom} ERREUR : {e}") - + logger.info("=" * 80) - + return {"diagnostic": "terminé"} - + except Exception as e: logger.error(f"Erreur diagnostic: {e}", exc_info=True) raise - + def lister_objets_com_disponibles(self): """🔍 Liste tous les objets COM disponibles dans Sage""" try: with self._com_context(), self._lock_com: - objets_trouves = { - "cial": [], - "cpta_application": [], - "document": [] - } - + objets_trouves = {"cial": [], "cpta_application": [], "document": []} + # 1. Objets sur self.cial for attr in dir(self.cial): - if not attr.startswith('_'): + if not attr.startswith("_"): try: obj = getattr(self.cial, attr) - objets_trouves["cial"].append({ - "nom": attr, - "type": str(type(obj)), - "callable": callable(obj) - }) + objets_trouves["cial"].append( + { + "nom": attr, + "type": str(type(obj)), + "callable": callable(obj), + } + ) except: pass - + # 2. Objets sur CptaApplication try: cpta = self.cial.CptaApplication for attr in dir(cpta): - if not attr.startswith('_'): + if not attr.startswith("_"): try: obj = getattr(cpta, attr) - objets_trouves["cpta_application"].append({ - "nom": attr, - "type": str(type(obj)), - "callable": callable(obj) - }) + objets_trouves["cpta_application"].append( + { + "nom": attr, + "type": str(type(obj)), + "callable": callable(obj), + } + ) except: pass except: pass - + # 3. Objets sur un document try: factory = self.cial.FactoryDocumentVente @@ -11388,28 +12296,29 @@ class SageConnector: if persist: doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + for attr in dir(doc): - if not attr.startswith('_'): + if not attr.startswith("_"): try: obj = getattr(doc, attr) - objets_trouves["document"].append({ - "nom": attr, - "type": str(type(obj)), - "callable": callable(obj) - }) + objets_trouves["document"].append( + { + "nom": attr, + "type": str(type(obj)), + "callable": callable(obj), + } + ) except: pass except: pass - + return objets_trouves - + except Exception as e: logger.error(f"Erreur listage objets COM: {e}", exc_info=True) raise - def explorer_methodes_impression(self): """Explore toutes les méthodes d'impression disponibles""" try: @@ -11417,15 +12326,15 @@ class SageConnector: # Charger un document de test factory = self.cial.FactoryDocumentVente persist = factory.List(1) - + if not persist: return {"error": "Aucun document trouvé"} - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + methods = {} - + # Tester différentes signatures de Print signatures_to_test = [ "Print", @@ -11433,9 +12342,9 @@ class SageConnector: "Export", "ExportToPDF", "SaveAs", - "GeneratePDF" + "GeneratePDF", ] - + for method_name in signatures_to_test: if hasattr(doc, method_name): try: @@ -11443,16 +12352,18 @@ class SageConnector: method = getattr(doc, method_name) methods[method_name] = { "exists": True, - "callable": callable(method) + "callable": callable(method), } except: - methods[method_name] = {"exists": True, "error": "Access error"} - + methods[method_name] = { + "exists": True, + "error": "Access error", + } + return methods - + except Exception as e: return {"error": str(e)} - def generer_pdf_document_via_print(self, numero: str, type_doc: int) -> bytes: """Utilise la méthode Print() native des documents Sage""" @@ -11461,67 +12372,69 @@ class SageConnector: # Charger le document factory = self.cial.FactoryDocumentVente persist = factory.ReadPiece(type_doc, numero) - + if not persist: persist = self._find_document_in_list(numero, type_doc) - + if not persist: raise ValueError(f"Document {numero} introuvable") - + doc = win32com.client.CastTo(persist, "IBODocumentVente3") doc.Read() - + # Créer un fichier temporaire import tempfile import os + temp_dir = tempfile.gettempdir() pdf_path = os.path.join(temp_dir, f"document_{numero}.pdf") - + # Utiliser Print() avec destination fichier PDF # Les codes de destination typiques dans Sage : # 0 = Imprimante par défaut # 1 = Aperçu # 2 = Fichier # 6 = PDF (dans certaines versions) - + try: # Tentative 1 : Print() avec paramètres doc.Print(Destination=6, FileName=pdf_path, Preview=False) except: # Tentative 2 : Print() simplifié try: - doc.Print(pdf_path) # Certaines versions acceptent juste le chemin + doc.Print( + pdf_path + ) # Certaines versions acceptent juste le chemin except: # Tentative 3 : PrintToFile() try: doc.PrintToFile(pdf_path) except AttributeError: raise RuntimeError("Aucune méthode d'impression disponible") - + # Lire le fichier PDF import time + max_wait = 10 waited = 0 while not os.path.exists(pdf_path) and waited < max_wait: time.sleep(0.5) waited += 0.5 - + if not os.path.exists(pdf_path): raise RuntimeError("Le fichier PDF n'a pas été généré") - - with open(pdf_path, 'rb') as f: + + with open(pdf_path, "rb") as f: pdf_bytes = f.read() - + # Nettoyer try: os.remove(pdf_path) except: pass - + return pdf_bytes - + except Exception as e: logger.error(f"Erreur génération PDF via Print(): {e}") raise - -