Sage100-ws/sage_connector.py

12440 lines
530 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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