Sage100-ws/sage_connector.py

10655 lines
451 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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 (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,
# Valeurs par défaut
"stock_reel": 0.0,
"stock_mini": 0.0,
"stock_maxi": 0.0,
}
articles.append(article)
# ========================================
# ÉTAPE 2 : ENRICHIR AVEC LE STOCK (si demandé)
# ========================================
if avec_stock and articles:
logger.info(
f"📦 Enrichissement stock pour {len(articles)} articles..."
)
# Essayer différentes tables de stock (selon version Sage)
tables_stock = ["F_ARTSTOCK", "F_DEPOTSTOCK", "F_STOCK"]
table_utilisee = None
for table in tables_stock:
try:
# Test si la table existe
cursor.execute(f"SELECT TOP 1 * FROM {table}")
table_utilisee = table
logger.info(f" ✅ Table de stock détectée : {table}")
break
except:
continue
if table_utilisee:
# Construire un mapping référence -> stock
stock_map = {}
try:
# ✅ CORRECTION : Requête adaptée selon la table avec les BONS noms de colonnes
if table_utilisee == "F_ARTSTOCK":
stock_query = """
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
FROM F_ARTSTOCK
GROUP BY AR_Ref
"""
elif table_utilisee == "F_DEPOTSTOCK":
stock_query = """
SELECT
AR_Ref,
SUM(ISNULL(DS_QteSto, 0)) as Stock_Total,
MIN(ISNULL(DS_QteMini, 0)) as Stock_Mini,
MAX(ISNULL(DS_QteMaxi, 0)) as Stock_Maxi
FROM F_DEPOTSTOCK
GROUP BY AR_Ref
"""
else: # F_STOCK ou autre
stock_query = f"""
SELECT
AR_Ref,
SUM(ISNULL(Quantite, 0)) as Stock_Total,
0 as Stock_Mini,
0 as Stock_Maxi
FROM {table_utilisee}
GROUP BY AR_Ref
"""
cursor.execute(stock_query)
stock_rows = cursor.fetchall()
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
),
}
logger.info(
f" ✅ Stocks chargés pour {len(stock_map)} articles"
)
# Enrichir les articles
for article in articles:
if article["reference"] in stock_map:
article.update(stock_map[article["reference"]])
except Exception as e:
logger.warning(
f" ⚠️ Erreur lecture stocks depuis {table_utilisee}: {e}"
)
else:
logger.warning(" ⚠️ Aucune table de stock trouvée")
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}")
raise RuntimeError(f"Erreur lecture articles: {str(e)}")
def lire_article(self, reference):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# ✅ MÊME REQUÊTE que lister_tous_articles (colonnes existantes uniquement)
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]), # Même valeur que code_ean
"type_article": row[8] if row[8] is not None else 0,
# Valeurs par défaut pour le stock
"stock_reel": 0.0,
"stock_mini": 0.0,
"stock_maxi": 0.0,
"stock_reserve": 0.0,
"stock_commande": 0.0,
"stock_disponible": 0.0,
}
# ✅ Enrichir avec les stocks (MÊME logique que lister_tous_articles)
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
FROM F_ARTSTOCK
WHERE AR_Ref = ?
""",
(reference.upper(),),
)
stock_row = cursor.fetchone()
if stock_row:
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
)
article["stock_disponible"] = article["stock_reel"] # Simplifié
except Exception as e:
logger.warning(
f"⚠️ Impossible de lire le stock pour {reference}: {e}"
)
return article
except Exception as e:
logger.error(f"❌ Erreur SQL article {reference}: {e}")
return None
def _lire_document_sql(self, numero: str, type_doc: int):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# ========================================
# VÉRIFIER SI DO_Domaine EXISTE
# ========================================
do_domaine_existe = False
try:
cursor.execute(
"SELECT TOP 1 DO_Domaine FROM F_DOCENTETE WHERE DO_Type = ?",
(type_doc,),
)
row_test = cursor.fetchone()
if row_test is not None:
do_domaine_existe = True
except:
do_domaine_existe = False
# ========================================
# LIRE L'ENTÊTE (avec filtre DO_Domaine si disponible)
# ========================================
if do_domaine_existe:
query = """
SELECT
DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC,
DO_Statut, DO_Tiers
FROM F_DOCENTETE
WHERE DO_Piece = ? AND DO_Type = ? AND DO_Domaine = 0
"""
else:
query = """
SELECT
DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC,
DO_Statut, DO_Tiers
FROM F_DOCENTETE
WHERE DO_Piece = ? AND DO_Type = ?
"""
cursor.execute(query, (numero, type_doc))
row = cursor.fetchone()
if not row:
# ✅ Si DO_Domaine n'existe pas, vérifier le préfixe
if not do_domaine_existe:
prefixes_vente = {
0: ["DE"],
10: ["BC"],
30: ["BL"],
50: ["AV", "AR"],
60: ["FA", "FC"],
}
prefixes_acceptes = prefixes_vente.get(type_doc, [])
est_vente = any(
numero.upper().startswith(p) for p in prefixes_acceptes
)
if not est_vente:
logger.warning(
f"Document {numero} semble être un document d'achat (préfixe non reconnu)"
)
return None
# Charger le client
client_code = self._safe_strip(row[6]) if row[6] else ""
client_intitule = ""
if client_code:
cursor.execute(
"""
SELECT CT_Intitule
FROM F_COMPTET
WHERE CT_Num = ?
""",
(client_code,),
)
client_row = cursor.fetchone()
if client_row:
client_intitule = self._safe_strip(client_row[0])
# ========================================
# LIRE LES LIGNES
# ========================================
cursor.execute(
"""
SELECT
AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT,
DL_Remise01REM_Valeur, DL_Remise01REM_Type
FROM F_DOCLIGNE
WHERE DO_Piece = ? AND DO_Type = ?
ORDER BY DL_Ligne
""",
(numero, type_doc),
)
lignes = []
for ligne_row in cursor.fetchall():
ligne = {
"article_code": self._safe_strip(ligne_row[0]),
"designation": self._safe_strip(ligne_row[1]),
"quantite": float(ligne_row[2]) if ligne_row[2] else 0.0,
"prix_unitaire_ht": (
float(ligne_row[3]) if ligne_row[3] else 0.0
),
"montant_ligne_ht": (
float(ligne_row[4]) if ligne_row[4] else 0.0
),
}
# Remise (si présente)
if ligne_row[5]:
ligne["remise_pourcentage"] = float(ligne_row[5])
ligne["remise_type"] = int(ligne_row[6]) if ligne_row[6] else 0
else:
ligne["remise_pourcentage"] = 0.0
ligne["remise_type"] = 0
lignes.append(ligne)
return {
"numero": self._safe_strip(row[0]),
"reference": self._safe_strip(row[2]),
"date": str(row[1]) if row[1] else "",
"client_code": client_code,
"client_intitule": client_intitule,
"total_ht": float(row[3]) if row[3] else 0.0,
"total_ttc": float(row[4]) if row[4] else 0.0,
"statut": row[5] if row[5] is not None else 0,
"lignes": lignes,
"nb_lignes": len(lignes),
}
except Exception as e:
logger.error(f"❌ Erreur SQL lecture document {numero}: {e}")
return None
def _lister_documents_avec_lignes_sql(
self, type_doc: int, filtre: str = "", limit: int = None
):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# ========================================
# ÉTAPE 0 : DIAGNOSTIC - Vérifier si DO_Domaine existe
# ========================================
do_domaine_existe = False
try:
cursor.execute(
"""
SELECT TOP 1 DO_Domaine
FROM F_DOCENTETE
WHERE DO_Type = ?
""",
(type_doc,),
)
row = cursor.fetchone()
if row is not None:
do_domaine_existe = True
logger.info(
f"[SQL] Colonne DO_Domaine détectée (valeur exemple: {row[0]})"
)
except Exception as e:
logger.info(f"[SQL] Colonne DO_Domaine non disponible: {e}")
do_domaine_existe = False
# ========================================
# ÉTAPE 1 : CONSTRUIRE LA REQUÊTE SELON DISPONIBILITÉ DO_Domaine
# ========================================
if do_domaine_existe:
# Version avec filtre DO_Domaine
query = """
SELECT
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.DO_Tiers, c.CT_Intitule
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
WHERE d.DO_Type = ?
AND d.DO_Domaine = 0
"""
logger.info(f"[SQL] Requête AVEC filtre DO_Domaine = 0")
else:
# Version SANS filtre DO_Domaine (utilise heuristique)
query = """
SELECT
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.DO_Tiers, c.CT_Intitule
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
WHERE d.DO_Type = ?
"""
logger.warning(
f"[SQL] Requête SANS filtre DO_Domaine (heuristique sur préfixe)"
)
params = [type_doc]
if filtre:
query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ?)"
params.extend([f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY d.DO_Date DESC"
if limit:
query = f"SELECT TOP ({limit}) * FROM ({query}) AS subquery"
cursor.execute(query, params)
entetes = cursor.fetchall()
logger.info(
f"[SQL] {len(entetes)} documents bruts récupérés (type={type_doc})"
)
documents = []
# ========================================
# ÉTAPE 2 : FILTRER PAR HEURISTIQUE SI DO_Domaine N'EXISTE PAS
# ========================================
for entete in entetes:
numero = self._safe_strip(entete.DO_Piece)
# Si DO_Domaine n'existe pas, filtrer par préfixe du numéro
if not do_domaine_existe:
# Heuristique :
# - Vente (clients) : BC, BL, FA, AV, DE
# - Achat (fournisseurs) : DA, RA, FAF, etc.
prefixes_vente = {
0: ["DE"], # Devis
10: ["BC"], # Bon de commande
30: ["BL"], # Bon de livraison
50: ["AV", "AR"], # Avoir
60: ["FA", "FC"], # Facture
}
prefixes_acceptes = prefixes_vente.get(type_doc, [])
if prefixes_acceptes:
# Vérifier si le numéro commence par un préfixe valide
est_vente = any(
numero.upper().startswith(p) for p in prefixes_acceptes
)
if not est_vente:
logger.debug(
f"[SQL] Document {numero} exclu (préfixe achat)"
)
continue
# Créer l'objet document de base
doc = {
"numero": numero,
"reference": self._safe_strip(entete.DO_Ref),
"date": str(entete.DO_Date) if entete.DO_Date else "",
"client_code": self._safe_strip(entete.DO_Tiers),
"client_intitule": self._safe_strip(entete.CT_Intitule),
"total_ht": (
float(entete.DO_TotalHT) if entete.DO_TotalHT else 0.0
),
"total_ttc": (
float(entete.DO_TotalTTC) if entete.DO_TotalTTC else 0.0
),
"statut": (
entete.DO_Statut if entete.DO_Statut is not None else 0
),
"lignes": [],
}
# ========================================
# CHARGER LES LIGNES
# ========================================
try:
cursor.execute(
"""
SELECT
AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT,
DL_Remise01REM_Valeur, DL_Remise01REM_Type
FROM F_DOCLIGNE
WHERE DO_Piece = ? AND DO_Type = ?
ORDER BY DL_Ligne
""",
(numero, type_doc),
)
lignes_rows = cursor.fetchall()
for ligne_row in lignes_rows:
ligne = {
"article_code": self._safe_strip(ligne_row.AR_Ref),
"designation": self._safe_strip(ligne_row.DL_Design),
"quantite": (
float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0
),
"prix_unitaire_ht": (
float(ligne_row.DL_PrixUnitaire)
if ligne_row.DL_PrixUnitaire
else 0.0
),
"montant_ligne_ht": (
float(ligne_row.DL_MontantHT)
if ligne_row.DL_MontantHT
else 0.0
),
}
# Remise (si présente)
if ligne_row.DL_Remise01REM_Valeur:
ligne["remise_pourcentage"] = float(
ligne_row.DL_Remise01REM_Valeur
)
ligne["remise_type"] = (
int(ligne_row.DL_Remise01REM_Type)
if ligne_row.DL_Remise01REM_Type
else 0
)
else:
ligne["remise_pourcentage"] = 0.0
ligne["remise_type"] = 0
doc["lignes"].append(ligne)
except Exception as e:
logger.warning(f"Erreur chargement lignes pour {numero}: {e}")
# Ajouter le nombre de lignes
doc["nb_lignes"] = len(doc["lignes"])
documents.append(doc)
methode = "DO_Domaine" if do_domaine_existe else "heuristique prefixe"
logger.info(
f"SQL: {len(documents)} documents (type={type_doc}, filtre={methode})"
)
return documents
except Exception as e:
logger.error(f"Erreur SQL listage documents avec lignes: {e}")
return []
def lister_tous_devis_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=0, filtre=filtre)
def lire_devis_cache(self, numero):
return self._lire_document_sql(numero, type_doc=0)
def lister_toutes_commandes_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=1, filtre=filtre)
def lire_commande_cache(self, numero):
return self._lire_document_sql(numero, type_doc=1)
def lister_toutes_factures_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=6, filtre=filtre)
def lire_facture_cache(self, numero):
return self._lire_document_sql(numero, type_doc=6)
def lister_tous_fournisseurs_cache(self, filtre=""):
return self.lister_tous_fournisseurs()
def lire_fournisseur_cache(self, code):
return self.lire_fournisseur()
def lister_toutes_livraisons_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=3, filtre=filtre)
def lire_livraison_cache(self, numero):
return self._lire_document_sql(numero, type_doc=3)
def lister_tous_avoirs_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=5, filtre=filtre)
def lire_avoir_cache(self, numero):
return self._lire_document_sql(numero, type_doc=5)
# =========================================================================
# CAST HELPERS
# =========================================================================
def _cast_client(self, persist_obj):
try:
obj = win32com.client.CastTo(persist_obj, "IBOClient3")
obj.Read()
return obj
except Exception as e:
logger.debug(f"❌ _cast_client échoue: {e}") # ✅ AJOUTER CE LOG
return None
def _cast_article(self, persist_obj):
try:
obj = win32com.client.CastTo(persist_obj, "IBOArticle3")
obj.Read()
return obj
except:
return None
def _extraire_client(self, client_obj):
try:
# === 1. CHAMPS OBLIGATOIRES ===
try:
numero = getattr(client_obj, "CT_Num", "").strip()
if not numero:
logger.debug("⚠️ Objet sans CT_Num, skip")
return None
except Exception as e:
logger.debug(f"❌ Erreur lecture CT_Num: {e}")
return None
try:
intitule = getattr(client_obj, "CT_Intitule", "").strip()
if not intitule:
logger.debug(f"⚠️ {numero} sans CT_Intitule")
except Exception as e:
logger.debug(f"⚠️ Erreur CT_Intitule sur {numero}: {e}")
intitule = ""
# === 2. CONSTRUCTION OBJET DE BASE ===
data = {
"numero": numero,
"intitule": intitule,
}
# === 3. TYPE DE TIERS (CLIENT/PROSPECT/FOURNISSEUR) ===
# CT_Qualite : 0=Client, 1=Fournisseur, 2=Client+Fournisseur, 3=Salarié, 4=Prospect
try:
qualite_code = getattr(client_obj, "CT_Qualite", None)
# Mapper les codes vers des libellés
qualite_map = {
0: "CLI", # Client
1: "FOU", # Fournisseur
2: "CLIFOU", # Client + Fournisseur
3: "SAL", # Salarié
4: "PRO", # Prospect
}
data["qualite"] = qualite_map.get(qualite_code, "CLI")
data["est_fournisseur"] = qualite_code in [1, 2]
except:
data["qualite"] = "CLI"
data["est_fournisseur"] = False
# CT_Prospect : 0=Non, 1=Oui
try:
data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1
except:
data["est_prospect"] = False
# Déterminer le type_tiers principal
if data["est_prospect"]:
data["type_tiers"] = "prospect"
elif data["est_fournisseur"] and data["qualite"] != "CLIFOU":
data["type_tiers"] = "fournisseur"
elif data["qualite"] == "CLIFOU":
data["type_tiers"] = "client_fournisseur"
else:
data["type_tiers"] = "client"
# === 4. STATUT (ACTIF / SOMMEIL) ===
try:
sommeil = getattr(client_obj, "CT_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["est_en_sommeil"] = sommeil == 1
except:
data["est_actif"] = True
data["est_en_sommeil"] = False
# === 5. TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) ===
try:
forme_juridique = getattr(client_obj, "CT_FormeJuridique", "").strip()
data["forme_juridique"] = forme_juridique
data["est_entreprise"] = bool(forme_juridique)
data["est_particulier"] = not bool(forme_juridique)
except:
data["forme_juridique"] = ""
data["est_entreprise"] = False
data["est_particulier"] = True
# === 6. IDENTITÉ PERSONNE PHYSIQUE (SI PARTICULIER) ===
try:
data["civilite"] = getattr(client_obj, "CT_Civilite", "").strip()
except:
data["civilite"] = ""
try:
data["nom"] = getattr(client_obj, "CT_Nom", "").strip()
except:
data["nom"] = ""
try:
data["prenom"] = getattr(client_obj, "CT_Prenom", "").strip()
except:
data["prenom"] = ""
# Nom complet formaté (pour particuliers)
if data.get("nom") or data.get("prenom"):
parts = []
if data.get("civilite"):
parts.append(data["civilite"])
if data.get("prenom"):
parts.append(data["prenom"])
if data.get("nom"):
parts.append(data["nom"])
data["nom_complet"] = " ".join(parts)
else:
data["nom_complet"] = ""
# === 7. CONTACT PRINCIPAL ===
try:
data["contact"] = getattr(client_obj, "CT_Contact", "").strip()
except:
data["contact"] = ""
# === 8. ADRESSE COMPLÈTE ===
try:
adresse_obj = getattr(client_obj, "Adresse", None)
if adresse_obj:
try:
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
except:
data["adresse"] = ""
try:
data["complement"] = getattr(
adresse_obj, "Complement", ""
).strip()
except:
data["complement"] = ""
try:
data["code_postal"] = getattr(
adresse_obj, "CodePostal", ""
).strip()
except:
data["code_postal"] = ""
try:
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
except:
data["ville"] = ""
try:
data["region"] = getattr(adresse_obj, "Region", "").strip()
except:
data["region"] = ""
try:
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
except:
data["pays"] = ""
else:
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
except Exception as e:
logger.debug(f"⚠️ Erreur adresse sur {numero}: {e}")
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
# === 9. TÉLÉCOMMUNICATIONS (DISTINCTION FIXE/MOBILE) ===
try:
telecom = getattr(client_obj, "Telecom", None)
if telecom:
# Téléphone FIXE
try:
data["telephone"] = getattr(telecom, "Telephone", "").strip()
except:
data["telephone"] = ""
# Téléphone MOBILE
try:
data["portable"] = getattr(telecom, "Portable", "").strip()
except:
data["portable"] = ""
# FAX
try:
data["telecopie"] = getattr(telecom, "Telecopie", "").strip()
except:
data["telecopie"] = ""
# EMAIL
try:
data["email"] = getattr(telecom, "EMail", "").strip()
except:
data["email"] = ""
# SITE WEB
try:
site = (
getattr(telecom, "Site", None)
or getattr(telecom, "Web", None)
or getattr(telecom, "SiteWeb", "")
)
data["site_web"] = str(site).strip() if site else ""
except:
data["site_web"] = ""
else:
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
except Exception as e:
logger.debug(f"⚠️ Erreur telecom sur {numero}: {e}")
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
# === 10. INFORMATIONS JURIDIQUES (ENTREPRISES) ===
try:
data["siret"] = getattr(client_obj, "CT_Siret", "").strip()
except:
data["siret"] = ""
try:
data["siren"] = getattr(client_obj, "CT_Siren", "").strip()
except:
data["siren"] = ""
try:
data["tva_intra"] = getattr(client_obj, "CT_Identifiant", "").strip()
except:
data["tva_intra"] = ""
try:
data["code_naf"] = (
getattr(client_obj, "CT_CodeNAF", "").strip()
or getattr(client_obj, "CT_APE", "").strip()
)
except:
data["code_naf"] = ""
# === 11. INFORMATIONS COMMERCIALES ===
try:
data["secteur"] = getattr(client_obj, "CT_Secteur", "").strip()
except:
data["secteur"] = ""
try:
effectif = getattr(client_obj, "CT_Effectif", None)
data["effectif"] = int(effectif) if effectif is not None else None
except:
data["effectif"] = None
try:
ca = getattr(client_obj, "CT_ChiffreAffaire", None)
data["ca_annuel"] = float(ca) if ca is not None else None
except:
data["ca_annuel"] = None
# Commercial rattaché
try:
data["commercial_code"] = getattr(client_obj, "CO_No", "").strip()
except:
try:
data["commercial_code"] = getattr(
client_obj, "CT_Commercial", ""
).strip()
except:
data["commercial_code"] = ""
if data.get("commercial_code"):
try:
commercial_obj = getattr(client_obj, "Commercial", None)
if commercial_obj:
commercial_obj.Read()
data["commercial_nom"] = getattr(
commercial_obj, "CO_Nom", ""
).strip()
else:
data["commercial_nom"] = ""
except:
data["commercial_nom"] = ""
else:
data["commercial_nom"] = ""
# === 12. CATÉGORIES ===
try:
data["categorie_tarifaire"] = getattr(client_obj, "N_CatTarif", None)
except:
data["categorie_tarifaire"] = None
try:
data["categorie_comptable"] = getattr(client_obj, "N_CatCompta", None)
except:
data["categorie_comptable"] = None
# === 13. INFORMATIONS FINANCIÈRES ===
try:
data["encours_autorise"] = float(getattr(client_obj, "CT_Encours", 0.0))
except:
data["encours_autorise"] = 0.0
try:
data["assurance_credit"] = float(
getattr(client_obj, "CT_Assurance", 0.0)
)
except:
data["assurance_credit"] = 0.0
try:
data["compte_general"] = getattr(client_obj, "CG_Num", "").strip()
except:
data["compte_general"] = ""
# === 14. DATES ===
try:
date_creation = getattr(client_obj, "CT_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except:
data["date_creation"] = ""
try:
date_modif = getattr(client_obj, "CT_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f"❌ ERREUR GLOBALE _extraire_client: {e}", exc_info=True)
return None
def _extraire_article(self, article_obj):
try:
data = {
# === IDENTIFICATION ===
"reference": getattr(article_obj, "AR_Ref", "").strip(),
"designation": getattr(article_obj, "AR_Design", "").strip(),
}
# === CODE EAN / CODE-BARRES ===
data["code_ean"] = ""
data["code_barre"] = ""
try:
# Essayer AR_CodeBarre (champ principal)
code_barre = getattr(article_obj, "AR_CodeBarre", "").strip()
if code_barre:
data["code_ean"] = code_barre
data["code_barre"] = code_barre
# Sinon essayer AR_CodeBarre1
if not data["code_ean"]:
code_barre1 = getattr(article_obj, "AR_CodeBarre1", "").strip()
if code_barre1:
data["code_ean"] = code_barre1
data["code_barre"] = code_barre1
except:
pass
# === PRIX ===
try:
data["prix_vente"] = float(getattr(article_obj, "AR_PrixVen", 0.0))
except:
data["prix_vente"] = 0.0
try:
data["prix_achat"] = float(getattr(article_obj, "AR_PrixAch", 0.0))
except:
data["prix_achat"] = 0.0
try:
data["prix_revient"] = float(
getattr(article_obj, "AR_PrixRevient", 0.0)
)
except:
data["prix_revient"] = 0.0
# === STOCK ===
try:
data["stock_reel"] = float(getattr(article_obj, "AR_Stock", 0.0))
except:
data["stock_reel"] = 0.0
try:
data["stock_mini"] = float(getattr(article_obj, "AR_StockMini", 0.0))
except:
data["stock_mini"] = 0.0
try:
data["stock_maxi"] = float(getattr(article_obj, "AR_StockMaxi", 0.0))
except:
data["stock_maxi"] = 0.0
# Stock réservé (en commande client)
try:
data["stock_reserve"] = float(getattr(article_obj, "AR_QteCom", 0.0))
except:
data["stock_reserve"] = 0.0
# Stock en commande fournisseur
try:
data["stock_commande"] = float(
getattr(article_obj, "AR_QteComFou", 0.0)
)
except:
data["stock_commande"] = 0.0
# Stock disponible (réel - réservé)
try:
data["stock_disponible"] = data["stock_reel"] - data["stock_reserve"]
except:
data["stock_disponible"] = data["stock_reel"]
# === DESCRIPTIONS ===
# Commentaire / Description détaillée
try:
commentaire = getattr(article_obj, "AR_Commentaire", "").strip()
data["description"] = commentaire
except:
data["description"] = ""
# Désignation complémentaire
try:
design2 = getattr(article_obj, "AR_Design2", "").strip()
data["designation_complementaire"] = design2
except:
data["designation_complementaire"] = ""
# === CLASSIFICATION ===
# Type d'article (0=Article, 1=Prestation, 2=Divers)
try:
type_art = getattr(article_obj, "AR_Type", 0)
data["type_article"] = type_art
data["type_article_libelle"] = {
0: "Article",
1: "Prestation",
2: "Divers",
}.get(type_art, "Inconnu")
except:
data["type_article"] = 0
data["type_article_libelle"] = "Article"
# Famille
try:
famille_code = getattr(article_obj, "FA_CodeFamille", "").strip()
data["famille_code"] = famille_code
# Charger le libellé de la famille si disponible
if famille_code:
try:
famille_obj = getattr(article_obj, "Famille", None)
if famille_obj:
famille_obj.Read()
data["famille_libelle"] = getattr(
famille_obj, "FA_Intitule", ""
).strip()
else:
data["famille_libelle"] = ""
except:
data["famille_libelle"] = ""
else:
data["famille_libelle"] = ""
except:
data["famille_code"] = ""
data["famille_libelle"] = ""
# === FOURNISSEUR PRINCIPAL ===
try:
fournisseur_code = getattr(article_obj, "CT_Num", "").strip()
data["fournisseur_principal"] = fournisseur_code
# Charger le nom du fournisseur si disponible
if fournisseur_code:
try:
fourn_obj = getattr(article_obj, "Fournisseur", None)
if fourn_obj:
fourn_obj.Read()
data["fournisseur_nom"] = getattr(
fourn_obj, "CT_Intitule", ""
).strip()
else:
data["fournisseur_nom"] = ""
except:
data["fournisseur_nom"] = ""
else:
data["fournisseur_nom"] = ""
except:
data["fournisseur_principal"] = ""
data["fournisseur_nom"] = ""
# === UNITÉS ===
try:
data["unite_vente"] = getattr(article_obj, "AR_UniteVen", "").strip()
except:
data["unite_vente"] = ""
try:
data["unite_achat"] = getattr(article_obj, "AR_UniteAch", "").strip()
except:
data["unite_achat"] = ""
# === CARACTÉRISTIQUES PHYSIQUES ===
try:
data["poids"] = float(getattr(article_obj, "AR_Poids", 0.0))
except:
data["poids"] = 0.0
try:
data["volume"] = float(getattr(article_obj, "AR_Volume", 0.0))
except:
data["volume"] = 0.0
# === STATUT ===
try:
sommeil = getattr(article_obj, "AR_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["en_sommeil"] = sommeil == 1
except:
data["est_actif"] = True
data["en_sommeil"] = False
# === TVA ===
try:
tva_code = getattr(article_obj, "TA_Code", "").strip()
data["tva_code"] = tva_code
# Essayer de charger le taux
try:
tva_obj = getattr(article_obj, "Taxe1", None)
if tva_obj:
tva_obj.Read()
data["tva_taux"] = float(getattr(tva_obj, "TA_Taux", 20.0))
else:
data["tva_taux"] = 20.0
except:
data["tva_taux"] = 20.0
except:
data["tva_code"] = ""
data["tva_taux"] = 20.0
# === DATES ===
try:
date_creation = getattr(article_obj, "AR_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except:
data["date_creation"] = ""
try:
date_modif = getattr(article_obj, "AR_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f"❌ Erreur extraction article: {e}", exc_info=True)
# Retourner structure minimale en cas d'erreur
return {
"reference": getattr(article_obj, "AR_Ref", "").strip(),
"designation": getattr(article_obj, "AR_Design", "").strip(),
"prix_vente": 0.0,
"stock_reel": 0.0,
"code_ean": "",
"description": "",
"designation_complementaire": "",
"prix_achat": 0.0,
"prix_revient": 0.0,
"stock_mini": 0.0,
"stock_maxi": 0.0,
"stock_reserve": 0.0,
"stock_commande": 0.0,
"stock_disponible": 0.0,
"code_barre": "",
"type_article": 0,
"type_article_libelle": "Article",
"famille_code": "",
"famille_libelle": "",
"fournisseur_principal": "",
"fournisseur_nom": "",
"unite_vente": "",
"unite_achat": "",
"poids": 0.0,
"volume": 0.0,
"est_actif": True,
"en_sommeil": False,
"tva_code": "",
"tva_taux": 20.0,
"date_creation": "",
"date_modification": "",
}
def _extraire_fournisseur_enrichi(self, fourn_obj):
try:
# === IDENTIFICATION ===
numero = getattr(fourn_obj, "CT_Num", "").strip()
if not numero:
return None
intitule = getattr(fourn_obj, "CT_Intitule", "").strip()
data = {
"numero": numero,
"intitule": intitule,
"type": 1, # Fournisseur
"est_fournisseur": True,
}
# === STATUT ===
try:
sommeil = getattr(fourn_obj, "CT_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["en_sommeil"] = sommeil == 1
except:
data["est_actif"] = True
data["en_sommeil"] = False
# === ADRESSE PRINCIPALE ===
try:
adresse_obj = getattr(fourn_obj, "Adresse", None)
if adresse_obj:
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
data["complement"] = getattr(adresse_obj, "Complement", "").strip()
data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip()
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
data["region"] = getattr(adresse_obj, "Region", "").strip()
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
# Adresse formatée complète
parties_adresse = []
if data["adresse"]:
parties_adresse.append(data["adresse"])
if data["complement"]:
parties_adresse.append(data["complement"])
if data["code_postal"] or data["ville"]:
ville_cp = f"{data['code_postal']} {data['ville']}".strip()
if ville_cp:
parties_adresse.append(ville_cp)
if data["pays"]:
parties_adresse.append(data["pays"])
data["adresse_complete"] = ", ".join(parties_adresse)
else:
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
data["adresse_complete"] = ""
except Exception as e:
logger.debug(f"⚠️ Erreur adresse fournisseur {numero}: {e}")
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
data["adresse_complete"] = ""
# === TÉLÉCOMMUNICATIONS ===
try:
telecom_obj = getattr(fourn_obj, "Telecom", None)
if telecom_obj:
data["telephone"] = getattr(telecom_obj, "Telephone", "").strip()
data["portable"] = getattr(telecom_obj, "Portable", "").strip()
data["telecopie"] = getattr(telecom_obj, "Telecopie", "").strip()
data["email"] = getattr(telecom_obj, "EMail", "").strip()
# Site web
try:
site = (
getattr(telecom_obj, "Site", None)
or getattr(telecom_obj, "Web", None)
or getattr(telecom_obj, "SiteWeb", "")
)
data["site_web"] = str(site).strip() if site else ""
except:
data["site_web"] = ""
else:
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
except Exception as e:
logger.debug(f"⚠️ Erreur telecom fournisseur {numero}: {e}")
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
# === INFORMATIONS FISCALES ===
# SIRET
try:
data["siret"] = getattr(fourn_obj, "CT_Siret", "").strip()
except:
data["siret"] = ""
# SIREN (extrait du SIRET si disponible)
try:
if data["siret"] and len(data["siret"]) >= 9:
data["siren"] = data["siret"][:9]
else:
data["siren"] = getattr(fourn_obj, "CT_Siren", "").strip()
except:
data["siren"] = ""
# TVA Intracommunautaire
try:
data["tva_intra"] = getattr(fourn_obj, "CT_Identifiant", "").strip()
except:
data["tva_intra"] = ""
# Code NAF/APE
try:
data["code_naf"] = (
getattr(fourn_obj, "CT_CodeNAF", "").strip()
or getattr(fourn_obj, "CT_APE", "").strip()
)
except:
data["code_naf"] = ""
# Forme juridique
try:
data["forme_juridique"] = getattr(
fourn_obj, "CT_FormeJuridique", ""
).strip()
except:
data["forme_juridique"] = ""
# === CATÉGORIES ===
# Catégorie tarifaire
try:
cat_tarif = getattr(fourn_obj, "N_CatTarif", None)
data["categorie_tarifaire"] = (
int(cat_tarif) if cat_tarif is not None else None
)
except:
data["categorie_tarifaire"] = None
# Catégorie comptable
try:
cat_compta = getattr(fourn_obj, "N_CatCompta", None)
data["categorie_comptable"] = (
int(cat_compta) if cat_compta is not None else None
)
except:
data["categorie_comptable"] = None
# === CONDITIONS DE RÈGLEMENT ===
try:
cond_regl = getattr(fourn_obj, "CT_CondRegl", "").strip()
data["conditions_reglement_code"] = cond_regl
# Charger le libellé si disponible
if cond_regl:
try:
# Essayer de charger l'objet ConditionReglement
cond_obj = getattr(fourn_obj, "ConditionReglement", None)
if cond_obj:
cond_obj.Read()
data["conditions_reglement_libelle"] = getattr(
cond_obj, "C_Intitule", ""
).strip()
else:
data["conditions_reglement_libelle"] = ""
except:
data["conditions_reglement_libelle"] = ""
else:
data["conditions_reglement_libelle"] = ""
except:
data["conditions_reglement_code"] = ""
data["conditions_reglement_libelle"] = ""
# Mode de règlement
try:
mode_regl = getattr(fourn_obj, "CT_ModeRegl", "").strip()
data["mode_reglement_code"] = mode_regl
# Libellé du mode de règlement
if mode_regl:
try:
mode_obj = getattr(fourn_obj, "ModeReglement", None)
if mode_obj:
mode_obj.Read()
data["mode_reglement_libelle"] = getattr(
mode_obj, "M_Intitule", ""
).strip()
else:
data["mode_reglement_libelle"] = ""
except:
data["mode_reglement_libelle"] = ""
else:
data["mode_reglement_libelle"] = ""
except:
data["mode_reglement_code"] = ""
data["mode_reglement_libelle"] = ""
# === COORDONNÉES BANCAIRES (IBAN) ===
data["coordonnees_bancaires"] = []
try:
# Sage peut avoir plusieurs comptes bancaires
factory_banque = getattr(fourn_obj, "FactoryBanque", None)
if factory_banque:
index = 1
while index <= 5: # Max 5 comptes bancaires
try:
banque_persist = factory_banque.List(index)
if banque_persist is None:
break
banque = win32com.client.CastTo(
banque_persist, "IBOBanque3"
)
banque.Read()
compte_bancaire = {
"banque_nom": getattr(
banque, "BI_Intitule", ""
).strip(),
"iban": getattr(banque, "RIB_Iban", "").strip(),
"bic": getattr(banque, "RIB_Bic", "").strip(),
"code_banque": getattr(
banque, "RIB_Banque", ""
).strip(),
"code_guichet": getattr(
banque, "RIB_Guichet", ""
).strip(),
"numero_compte": getattr(
banque, "RIB_Compte", ""
).strip(),
"cle_rib": getattr(banque, "RIB_Cle", "").strip(),
}
# Ne garder que si IBAN ou RIB complet
if (
compte_bancaire["iban"]
or compte_bancaire["numero_compte"]
):
data["coordonnees_bancaires"].append(compte_bancaire)
index += 1
except:
break
except Exception as e:
logger.debug(
f"⚠️ Erreur coordonnées bancaires fournisseur {numero}: {e}"
)
# IBAN principal (premier de la liste)
if data["coordonnees_bancaires"]:
data["iban_principal"] = data["coordonnees_bancaires"][0].get(
"iban", ""
)
data["bic_principal"] = data["coordonnees_bancaires"][0].get("bic", "")
else:
data["iban_principal"] = ""
data["bic_principal"] = ""
# === CONTACTS ===
data["contacts"] = []
try:
factory_contact = getattr(fourn_obj, "FactoryContact", None)
if factory_contact:
index = 1
while index <= 20: # Max 20 contacts
try:
contact_persist = factory_contact.List(index)
if contact_persist is None:
break
contact = win32com.client.CastTo(
contact_persist, "IBOContact3"
)
contact.Read()
contact_data = {
"nom": getattr(contact, "CO_Nom", "").strip(),
"prenom": getattr(contact, "CO_Prenom", "").strip(),
"fonction": getattr(contact, "CO_Fonction", "").strip(),
"service": getattr(contact, "CO_Service", "").strip(),
"telephone": getattr(
contact, "CO_Telephone", ""
).strip(),
"portable": getattr(contact, "CO_Portable", "").strip(),
"email": getattr(contact, "CO_EMail", "").strip(),
}
# Nom complet formaté
nom_complet = f"{contact_data['prenom']} {contact_data['nom']}".strip()
if nom_complet:
contact_data["nom_complet"] = nom_complet
else:
contact_data["nom_complet"] = contact_data["nom"]
# Ne garder que si nom existe
if contact_data["nom"]:
data["contacts"].append(contact_data)
index += 1
except:
break
except Exception as e:
logger.debug(f"⚠️ Erreur contacts fournisseur {numero}: {e}")
# Nombre de contacts
data["nb_contacts"] = len(data["contacts"])
# Contact principal (premier de la liste)
if data["contacts"]:
data["contact_principal"] = data["contacts"][0]
else:
data["contact_principal"] = None
# === STATISTIQUES & INFORMATIONS COMMERCIALES ===
# Encours autorisé
try:
data["encours_autorise"] = float(getattr(fourn_obj, "CT_Encours", 0.0))
except:
data["encours_autorise"] = 0.0
# Chiffre d'affaires annuel
try:
data["ca_annuel"] = float(getattr(fourn_obj, "CT_ChiffreAffaire", 0.0))
except:
data["ca_annuel"] = 0.0
# Compte général
try:
data["compte_general"] = getattr(fourn_obj, "CG_Num", "").strip()
except:
data["compte_general"] = ""
# === DATES ===
try:
date_creation = getattr(fourn_obj, "CT_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except:
data["date_creation"] = ""
try:
date_modif = getattr(fourn_obj, "CT_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f"❌ Erreur extraction fournisseur: {e}", exc_info=True)
# Retourner structure minimale en cas d'erreur
return {
"numero": getattr(fourn_obj, "CT_Num", "").strip(),
"intitule": getattr(fourn_obj, "CT_Intitule", "").strip(),
"type": 1,
"est_fournisseur": True,
"est_actif": True,
"en_sommeil": False,
"adresse": "",
"complement": "",
"code_postal": "",
"ville": "",
"region": "",
"pays": "",
"adresse_complete": "",
"telephone": "",
"portable": "",
"telecopie": "",
"email": "",
"site_web": "",
"siret": "",
"siren": "",
"tva_intra": "",
"code_naf": "",
"forme_juridique": "",
"categorie_tarifaire": None,
"categorie_comptable": None,
"conditions_reglement_code": "",
"conditions_reglement_libelle": "",
"mode_reglement_code": "",
"mode_reglement_libelle": "",
"iban_principal": "",
"bic_principal": "",
"coordonnees_bancaires": [],
"contacts": [],
"nb_contacts": 0,
"contact_principal": None,
"encours_autorise": 0.0,
"ca_annuel": 0.0,
"compte_general": "",
"date_creation": "",
"date_modification": "",
}
def creer_devis_enrichi(self, devis_data: dict, forcer_brouillon: bool = False):
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
logger.info(
f"🚀 Début création devis pour client {devis_data['client']['code']} "
f"(brouillon={forcer_brouillon})"
)
try:
with self._com_context(), self._lock_com:
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug("✅ Transaction Sage démarrée")
except Exception as e:
logger.warning(f"⚠️ BeginTrans échoué: {e}")
try:
# ===== CRÉATION DOCUMENT =====
process = self.cial.CreateProcess_Document(0)
doc = process.Document
try:
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
except:
pass
logger.info("📄 Document devis créé")
# ===== DATE =====
import pywintypes
if isinstance(devis_data["date_devis"], str):
try:
date_obj = datetime.fromisoformat(devis_data["date_devis"])
except:
date_obj = datetime.now()
elif isinstance(devis_data["date_devis"], date):
date_obj = datetime.combine(
devis_data["date_devis"], datetime.min.time()
)
else:
date_obj = datetime.now()
doc.DO_Date = pywintypes.Time(date_obj)
logger.info(f"📅 Date définie: {date_obj.date()}")
# ===== CLIENT =====
factory_client = self.cial.CptaApplication.FactoryClient
persist_client = factory_client.ReadNumero(
devis_data["client"]["code"]
)
if not persist_client:
raise ValueError(
f"❌ Client {devis_data['client']['code']} introuvable"
)
client_obj = self._cast_client(persist_client)
if not client_obj:
raise ValueError(
f"❌ Impossible de charger le client {devis_data['client']['code']}"
)
doc.SetDefaultClient(client_obj)
# ✅ STATUT: Définir SEULEMENT si brouillon demandé
doc.DO_Statut = 2
logger.info("📊 Statut forcé: 0 (Brouillon)")
# Sinon, laisser Sage décider (généralement 2 = Accepté)
doc.Write()
logger.info(f"👤 Client {devis_data['client']['code']} associé")
# ===== LIGNES =====
try:
factory_lignes = doc.FactoryDocumentLigne
except:
factory_lignes = doc.FactoryDocumentVenteLigne
factory_article = self.cial.FactoryArticle
logger.info(f"📦 Ajout de {len(devis_data['lignes'])} lignes...")
for idx, ligne_data in enumerate(devis_data["lignes"], 1):
logger.debug(
f"--- Ligne {idx}: {ligne_data['article_code']} ---"
)
# Charger l'article
persist_article = factory_article.ReadReference(
ligne_data["article_code"]
)
if not persist_article:
raise ValueError(
f"❌ Article {ligne_data['article_code']} introuvable"
)
article_obj = win32com.client.CastTo(
persist_article, "IBOArticle3"
)
article_obj.Read()
prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0))
designation_sage = getattr(article_obj, "AR_Design", "")
if prix_sage == 0:
logger.warning(
f"⚠️ Article {ligne_data['article_code']} a un prix = 0€"
)
# Créer la ligne
ligne_persist = factory_lignes.Create()
try:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
except:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentVenteLigne3"
)
quantite = float(ligne_data["quantite"])
try:
ligne_obj.SetDefaultArticleReference(
ligne_data["article_code"], quantite
)
except:
try:
ligne_obj.SetDefaultArticle(article_obj, quantite)
except:
ligne_obj.DL_Design = (
designation_sage
or ligne_data.get("designation", "")
)
ligne_obj.DL_Qte = quantite
# Prix
prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
prix_a_utiliser = ligne_data.get("prix_unitaire_ht")
if prix_a_utiliser is not None and prix_a_utiliser > 0:
ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser)
elif prix_auto == 0:
if prix_sage == 0:
raise ValueError(
f"Prix nul pour article {ligne_data['article_code']}"
)
ligne_obj.DL_PrixUnitaire = float(prix_sage)
# Remise
remise = ligne_data.get("remise_pourcentage", 0)
if remise > 0:
try:
ligne_obj.DL_Remise01REM_Valeur = float(remise)
ligne_obj.DL_Remise01REM_Type = 0
except Exception as e:
logger.warning(f"⚠️ Remise non appliquée: {e}")
ligne_obj.Write()
logger.info(f"{len(devis_data['lignes'])} lignes écrites")
# ===== VALIDATION =====
doc.Write()
# ✅ PROCESS() uniquement si pas en brouillon
if not forcer_brouillon:
logger.info("🔄 Lancement Process()...")
process.Process()
else:
# En brouillon, Process() peut échouer, on essaie mais on ignore l'erreur
try:
process.Process()
logger.info("✅ Process() appelé (brouillon)")
except:
logger.debug("⚠️ Process() ignoré pour brouillon")
# ===== RÉCUPÉRATION NUMÉRO =====
numero_devis = None
# Méthode 1: DocumentResult
try:
doc_result = process.DocumentResult
if doc_result:
doc_result = win32com.client.CastTo(
doc_result, "IBODocumentVente3"
)
doc_result.Read()
numero_devis = getattr(doc_result, "DO_Piece", "")
except:
pass
# Méthode 2: Document direct
if not numero_devis:
numero_devis = getattr(doc, "DO_Piece", "")
# Méthode 3: SetDefaultNumPiece
if not numero_devis:
try:
doc.SetDefaultNumPiece()
doc.Write()
doc.Read()
numero_devis = getattr(doc, "DO_Piece", "")
except:
pass
if not numero_devis:
raise RuntimeError("❌ Numéro devis vide après création")
logger.info(f"📄 Numéro: {numero_devis}")
# ===== COMMIT =====
if transaction_active:
try:
self.cial.CptaApplication.CommitTrans()
logger.info("✅ Transaction committée")
except:
pass
# ===== RELECTURE SIMPLIFIÉE (pas d'attente excessive) =====
# Attendre juste 500ms pour l'indexation
time.sleep(0.5)
factory_doc = self.cial.FactoryDocumentVente
persist_reread = factory_doc.ReadPiece(0, numero_devis)
if not persist_reread:
# Si ReadPiece échoue, chercher dans les 100 premiers
logger.debug("ReadPiece échoué, recherche dans List()...")
index = 1
while index < 100:
try:
persist_test = factory_doc.List(index)
if persist_test is None:
break
doc_test = win32com.client.CastTo(
persist_test, "IBODocumentVente3"
)
doc_test.Read()
if (
getattr(doc_test, "DO_Type", -1) == 0
and getattr(doc_test, "DO_Piece", "")
== numero_devis
):
persist_reread = persist_test
logger.info(f"✅ Document trouvé à l'index {index}")
break
index += 1
except:
index += 1
# Extraction des totaux
if persist_reread:
doc_final = win32com.client.CastTo(
persist_reread, "IBODocumentVente3"
)
doc_final.Read()
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
statut_final = getattr(doc_final, "DO_Statut", 0)
else:
# Fallback: calculer manuellement
total_calcule = sum(
l.get("montant_ligne_ht", 0) for l in devis_data["lignes"]
)
total_ht = total_calcule
total_ttc = round(total_calcule * 1.20, 2)
statut_final = 0 if forcer_brouillon else 2
logger.info(f"💰 Total HT: {total_ht}")
logger.info(f"💰 Total TTC: {total_ttc}")
logger.info(f"📊 Statut final: {statut_final}")
logger.info(
f"✅ ✅ ✅ DEVIS CRÉÉ: {numero_devis} - {total_ttc}€ TTC ✅ ✅ ✅"
)
return {
"numero_devis": numero_devis,
"total_ht": total_ht,
"total_ttc": total_ttc,
"nb_lignes": len(devis_data["lignes"]),
"client_code": devis_data["client"]["code"],
"date_devis": str(date_obj.date()),
"statut": statut_final,
}
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
logger.error("❌ Transaction annulée (rollback)")
except:
pass
raise
except Exception as e:
logger.error(f"❌ ERREUR CRÉATION DEVIS: {e}", exc_info=True)
raise RuntimeError(f"Échec création devis: {str(e)}")
def lire_devis(self, numero_devis):
try:
# Lire le devis via SQL
devis = self._lire_document_sql(numero_devis, type_doc=0)
if not devis:
return None
# ✅ Vérifier si transformé (via SQL pour DO_Ref)
try:
verification = self.verifier_si_deja_transforme_sql(numero_devis, 0)
devis["a_deja_ete_transforme"] = verification.get(
"deja_transforme", False
)
devis["documents_cibles"] = verification.get("documents_cibles", [])
except Exception as e:
logger.warning(f"⚠️ Erreur vérification transformation: {e}")
devis["a_deja_ete_transforme"] = False
devis["documents_cibles"] = []
logger.info(
f"✅ SQL: Devis {numero_devis} lu ({len(devis['lignes'])} lignes)"
)
return devis
except Exception as e:
logger.error(f"❌ Erreur SQL lecture devis {numero_devis}: {e}")
return None
def lire_document(self, numero, type_doc):
return self._lire_document_sql(numero, type_doc)
def verifier_si_deja_transforme_sql(
self, numero_source: str, type_source: int
) -> Dict:
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# Types cibles selon le type source
types_cibles_map = {
0: [10, 60], # Devis → Commande ou Facture
10: [30, 60], # Commande → BL ou Facture
30: [60], # BL → Facture
}
types_cibles = types_cibles_map.get(type_source, [])
if not types_cibles:
return {"deja_transforme": False, "documents_cibles": []}
# Construire la clause WHERE avec OR pour chaque type
placeholders = ",".join(["?"] * len(types_cibles))
query = f"""
SELECT DO_Piece, DO_Type, DO_Date, DO_Ref, DO_TotalTTC, DO_Statut
FROM F_DOCENTETE
WHERE DO_Ref LIKE ?
AND DO_Type IN ({placeholders})
ORDER BY DO_Date DESC
"""
params = [f"%{numero_source}%"] + types_cibles
cursor.execute(query, params)
rows = cursor.fetchall()
documents_cibles = []
for row in rows:
# Vérifier que DO_Ref correspond exactement (éviter les faux positifs)
ref_origine = self._safe_strip(row.DO_Ref)
if numero_source in ref_origine or ref_origine == numero_source:
type_libelle = {
10: "Bon de commande",
30: "Bon de livraison",
60: "Facture",
}.get(row.DO_Type, f"Type {row.DO_Type}")
documents_cibles.append(
{
"numero": self._safe_strip(row.DO_Piece),
"type": row.DO_Type,
"type_libelle": type_libelle,
"date": str(row.DO_Date) if row.DO_Date else "",
"reference": ref_origine,
"total_ttc": (
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
),
"statut": (
row.DO_Statut if row.DO_Statut is not None else 0
),
"methode_detection": "sql_do_ref",
}
)
logger.info(
f"✅ SQL: Vérification {numero_source}{len(documents_cibles)} transformation(s)"
)
return {
"deja_transforme": len(documents_cibles) > 0,
"nb_transformations": len(documents_cibles),
"documents_cibles": documents_cibles,
}
except Exception as e:
logger.error(f"❌ Erreur SQL vérification transformation: {e}")
return {"deja_transforme": False, "documents_cibles": []}
def _get_type_libelle(self, type_doc: int) -> str:
"""Retourne le libellé d'un type de document"""
types = {
0: "Devis",
10: "Bon de commande",
20: "Préparation",
30: "Bon de livraison",
40: "Bon de retour",
50: "Bon d'avoir",
60: "Facture",
}
return types.get(type_doc, f"Type {type_doc}")
def transformer_document(
self, numero_source, type_source, type_cible, ignorer_controle_stock=True
):
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
type_source = int(type_source)
type_cible = int(type_cible)
logger.info(
f"[TRANSFORM] {numero_source} (type {type_source}) → type {type_cible}"
)
transformations_valides = {
(0, 10), # Devis → Commande
(0, 60), # Devis → Facture
(10, 30), # Commande → Bon de livraison
(10, 60), # Commande → Facture
(30, 60), # Bon de livraison → Facture
}
if (type_source, type_cible) not in transformations_valides:
raise ValueError(
f"Transformation non autorisée: {type_source} -> {type_cible}"
)
# ========================================
# FONCTION UTILITAIRE
# ========================================
def lire_erreurs_sage(obj, nom_obj=""):
"""Lit toutes les erreurs d'un objet Sage COM"""
erreurs = []
try:
if not hasattr(obj, "Errors") or obj.Errors is None:
return erreurs
nb_erreurs = 0
try:
nb_erreurs = obj.Errors.Count
except:
return erreurs
if nb_erreurs == 0:
return erreurs
for i in range(1, nb_erreurs + 1):
try:
err = None
try:
err = obj.Errors.Item(i)
except:
try:
err = obj.Errors(i)
except:
try:
err = obj.Errors.Item(i - 1)
except:
pass
if err is not None:
description = ""
field = ""
number = ""
for attr in ["Description", "Descr", "Message", "Text"]:
try:
val = getattr(err, attr, None)
if val:
description = str(val)
break
except:
pass
for attr in ["Field", "FieldName", "Champ", "Property"]:
try:
val = getattr(err, attr, None)
if val:
field = str(val)
break
except:
pass
for attr in ["Number", "Code", "ErrorCode", "Numero"]:
try:
val = getattr(err, attr, None)
if val is not None:
number = str(val)
break
except:
pass
if description or field or number:
erreurs.append(
{
"source": nom_obj,
"index": i,
"description": description or "Erreur inconnue",
"field": field or "?",
"number": number or "?",
}
)
except Exception as e:
logger.debug(f"Erreur lecture erreur {i}: {e}")
continue
except Exception as e:
logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}")
return erreurs
# Vérification doublons
logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...")
verification = self.verifier_si_deja_transforme(numero_source, type_source)
if verification["deja_transforme"]:
docs_existants = verification["documents_cibles"]
docs_meme_type = [d for d in docs_existants if d["type"] == type_cible]
if docs_meme_type:
nums = [d["numero"] for d in docs_meme_type]
error_msg = (
f"❌ Le document {numero_source} a déjà été transformé "
f"en {self._get_type_libelle(type_cible)}. "
f"Document(s) existant(s) : {', '.join(nums)}"
)
logger.error(f"[TRANSFORM] {error_msg}")
raise ValueError(error_msg)
try:
with self._com_context(), self._lock_com:
# ========================================
# ÉTAPE 1 : LIRE LE DOCUMENT SOURCE
# ========================================
factory = self.cial.FactoryDocumentVente
persist_source = factory.ReadPiece(type_source, numero_source)
if not persist_source:
persist_source = self._find_document_in_list(
numero_source, type_source
)
if not persist_source:
raise ValueError(
f"Document {numero_source} (type {type_source}) introuvable"
)
doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3")
doc_source.Read()
statut_actuel = getattr(doc_source, "DO_Statut", 0)
logger.info(
f"[TRANSFORM] Source: type={type_source}, statut={statut_actuel}"
)
# ========================================
# ÉTAPE 2 : EXTRAIRE DONNÉES SOURCE
# ========================================
logger.info("[TRANSFORM] Extraction données source...")
# Client
client_code = ""
client_obj_source = None
try:
client_obj_source = getattr(doc_source, "Client", None)
if client_obj_source:
client_obj_source.Read()
client_code = getattr(client_obj_source, "CT_Num", "").strip()
except Exception as e:
logger.error(f"Erreur lecture client source: {e}")
raise ValueError("Impossible de lire le client du document source")
if not client_code:
raise ValueError("Client introuvable dans document source")
logger.info(f"[TRANSFORM] Client: {client_code}")
# Date et référence
date_source = getattr(doc_source, "DO_Date", None)
reference_pour_cible = numero_source
logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}")
# Champs à copier
champs_source = {}
champs_a_copier = [
"DO_Souche",
"DO_Regime",
"DO_CodeJournal",
"DO_Coord01",
"DO_TypeCalcul",
"DO_Devise",
"DO_Cours",
"DO_Period",
"DO_Expedit",
"DO_NbFacture",
"DO_BLFact",
"DO_TxEsworte",
"DO_Reliquat",
"DO_Imprim",
"DO_Ventile",
"DO_Motif",
]
for champ in champs_a_copier:
try:
val = getattr(doc_source, champ, None)
if val is not None:
champs_source[champ] = val
except:
pass
# Infos règlement client
client_mode_regl = None
client_cond_regl = None
if client_obj_source:
try:
client_mode_regl = getattr(
client_obj_source, "CT_ModeRegl", None
)
if client_mode_regl:
logger.info(
f"[TRANSFORM] Mode règlement client: {client_mode_regl}"
)
except:
pass
try:
client_cond_regl = getattr(
client_obj_source, "CT_CondRegl", None
)
if client_cond_regl:
logger.info(
f"[TRANSFORM] Conditions règlement client: {client_cond_regl}"
)
except:
pass
# ========================================
# ÉTAPE 3 : EXTRACTION LIGNES
# ========================================
lignes_source = []
factory_article = self.cial.FactoryArticle
try:
factory_lignes_source = getattr(
doc_source, "FactoryDocumentLigne", None
)
if not factory_lignes_source:
factory_lignes_source = getattr(
doc_source, "FactoryDocumentVenteLigne", None
)
if factory_lignes_source:
index = 1
while index <= 1000:
try:
ligne_p = factory_lignes_source.List(index)
if ligne_p is None:
break
ligne = win32com.client.CastTo(
ligne_p, "IBODocumentLigne3"
)
ligne.Read()
# Référence article
article_ref = ""
try:
article_ref = getattr(ligne, "AR_Ref", "").strip()
if not article_ref:
article_obj = getattr(ligne, "Article", None)
if article_obj:
article_obj.Read()
article_ref = getattr(
article_obj, "AR_Ref", ""
).strip()
except:
pass
# Prix unitaire
prix_unitaire = float(
getattr(ligne, "DL_PrixUnitaire", 0.0)
)
# Si prix = 0, récupérer depuis l'article
if prix_unitaire == 0 and article_ref:
try:
persist_article = factory_article.ReadReference(
article_ref
)
if persist_article:
article_obj_price = win32com.client.CastTo(
persist_article, "IBOArticle3"
)
article_obj_price.Read()
prix_unitaire = float(
getattr(
article_obj_price, "AR_PrixVen", 0.0
)
)
logger.info(
f" Prix récupéré depuis article {article_ref}: {prix_unitaire}"
)
except:
pass
# ✅ NOUVEAU : Récupérer le DL_MvtStock de la ligne source
mvt_stock_source = 0
try:
mvt_stock_source = int(
getattr(ligne, "DL_MvtStock", 0)
)
except:
pass
lignes_source.append(
{
"article_ref": article_ref,
"designation": getattr(ligne, "DL_Design", ""),
"quantite": float(
getattr(ligne, "DL_Qte", 0.0)
),
"prix_unitaire": prix_unitaire,
"remise": float(
getattr(ligne, "DL_Remise01REM_Valeur", 0.0)
),
"type_remise": int(
getattr(ligne, "DL_Remise01REM_Type", 0)
),
"montant_ht": float(
getattr(ligne, "DL_MontantHT", 0.0)
),
"mvt_stock": mvt_stock_source, # ✅ Conservé !
}
)
index += 1
except Exception as e:
logger.debug(f"Erreur ligne {index}: {e}")
break
except Exception as e:
logger.error(f"Erreur extraction lignes: {e}")
raise ValueError(
"Impossible d'extraire les lignes du document source"
)
nb_lignes = len(lignes_source)
logger.info(f"[TRANSFORM] {nb_lignes} lignes extraites")
if nb_lignes == 0:
raise ValueError("Document source vide (aucune ligne)")
total_attendu_ht = sum(l["montant_ht"] for l in lignes_source)
logger.info(
f"[TRANSFORM] Total HT attendu (calculé): {total_attendu_ht}"
)
# ========================================
# ÉTAPE 4 : TRANSACTION
# ========================================
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug("[TRANSFORM] Transaction démarrée")
except:
logger.debug("[TRANSFORM] BeginTrans non disponible")
try:
# ========================================
# ÉTAPE 5 : CRÉER DOCUMENT CIBLE
# ========================================
logger.info(f"[TRANSFORM] Création document type {type_cible}...")
process = self.cial.CreateProcess_Document(type_cible)
if not process:
raise RuntimeError(
f"CreateProcess_Document({type_cible}) a retourné None"
)
doc_cible = process.Document
try:
doc_cible = win32com.client.CastTo(
doc_cible, "IBODocumentVente3"
)
except:
pass
logger.info("[TRANSFORM] Document cible créé")
# ========================================
# ÉTAPE 6 : DÉFINIR LA DATE
# ========================================
import pywintypes
if date_source:
try:
doc_cible.DO_Date = date_source
logger.info(f"[TRANSFORM] Date copiée: {date_source}")
except Exception as e:
logger.warning(f"Impossible de copier date: {e}")
doc_cible.DO_Date = pywintypes.Time(datetime.now())
else:
doc_cible.DO_Date = pywintypes.Time(datetime.now())
# ========================================
# ÉTAPE 7 : ASSOCIER LE CLIENT
# ========================================
logger.info(f"[TRANSFORM] Association client {client_code}...")
factory_client = self.cial.CptaApplication.FactoryClient
persist_client = factory_client.ReadNumero(client_code)
if not persist_client:
raise ValueError(f"Client {client_code} introuvable")
client_obj_cible = self._cast_client(persist_client)
if not client_obj_cible:
raise ValueError(f"Impossible de charger client {client_code}")
try:
doc_cible.SetDefaultClient(client_obj_cible)
logger.info("[TRANSFORM] SetDefaultClient() appelé")
except Exception as e:
try:
doc_cible.Client = client_obj_cible
logger.info(
"[TRANSFORM] Client assigné via propriété .Client"
)
except Exception as e2:
raise ValueError(f"Impossible d'associer le client: {e2}")
# DO_Ref AVANT 1er Write
try:
doc_cible.DO_Ref = reference_pour_cible
logger.info(
f"[TRANSFORM] DO_Ref défini: {reference_pour_cible}"
)
except Exception as e:
logger.warning(f"Impossible de définir DO_Ref: {e}")
# ========================================
# ÉTAPE 7.5 : COPIER CHAMPS
# ========================================
for champ, valeur in champs_source.items():
try:
setattr(doc_cible, champ, valeur)
logger.debug(f"[TRANSFORM] {champ} copié: {valeur}")
except Exception as e:
logger.debug(f"[TRANSFORM] {champ} non copié: {e}")
# ========================================
# ÉTAPE 7.6 : CHAMPS SPÉCIFIQUES FACTURES
# ========================================
if type_cible == 60:
logger.info("[TRANSFORM] Configuration champs factures...")
# DO_Souche
try:
souche = champs_source.get("DO_Souche", 0)
doc_cible.DO_Souche = souche
logger.debug(f" ✅ DO_Souche: {souche}")
except Exception as e:
logger.debug(f" ⚠️ DO_Souche: {e}")
# DO_Regime
try:
regime = champs_source.get("DO_Regime", 0)
doc_cible.DO_Regime = regime
logger.debug(f" ✅ DO_Regime: {regime}")
except Exception as e:
logger.debug(f" ⚠️ DO_Regime: {e}")
# DO_Transaction
try:
doc_cible.DO_Transaction = 11
logger.debug(f" ✅ DO_Transaction: 11")
except Exception as e:
logger.debug(f" ⚠️ DO_Transaction: {e}")
# Mode règlement
if client_mode_regl:
try:
doc_cible.DO_ModeRegl = client_mode_regl
logger.info(f" ✅ DO_ModeRegl: {client_mode_regl}")
except Exception as e:
logger.debug(f" ⚠️ DO_ModeRegl: {e}")
# Conditions règlement
if client_cond_regl:
try:
doc_cible.DO_CondRegl = client_cond_regl
logger.info(f" ✅ DO_CondRegl: {client_cond_regl}")
except Exception as e:
logger.debug(f" ⚠️ DO_CondRegl: {e}")
# 1er Write
doc_cible.Write()
logger.info("[TRANSFORM] Document initialisé (1er Write)")
# ========================================
# ÉTAPE 8 : COPIER LIGNES (AVEC GESTION STOCK)
# ========================================
logger.info(f"[TRANSFORM] Copie de {nb_lignes} lignes...")
if ignorer_controle_stock:
logger.info("[TRANSFORM] ⚠️ Contrôle de stock DÉSACTIVÉ")
try:
factory_lignes_cible = doc_cible.FactoryDocumentLigne
except:
factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne
lignes_creees = 0
for idx, ligne_data in enumerate(lignes_source, 1):
logger.debug(f"[TRANSFORM] Ligne {idx}/{nb_lignes}")
article_ref = ligne_data["article_ref"]
if not article_ref:
logger.warning(
f"Ligne {idx}: pas de référence article, skip"
)
continue
persist_article = factory_article.ReadReference(article_ref)
if not persist_article:
logger.warning(
f"Ligne {idx}: article {article_ref} introuvable, skip"
)
continue
article_obj = win32com.client.CastTo(
persist_article, "IBOArticle3"
)
article_obj.Read()
ligne_persist = factory_lignes_cible.Create()
try:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
except:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentVenteLigne3"
)
quantite = ligne_data["quantite"]
prix = ligne_data["prix_unitaire"]
# ✅ CRITIQUE : Associer l'article AVANT de définir les flags stock
try:
ligne_obj.SetDefaultArticleReference(article_ref, quantite)
logger.debug(f" SetDefaultArticleReference OK")
except Exception as e1:
logger.debug(f" SetDefaultArticleReference échoué: {e1}")
try:
ligne_obj.SetDefaultArticle(article_obj, quantite)
logger.debug(f" SetDefaultArticle OK")
except Exception as e2:
logger.debug(f" SetDefaultArticle échoué: {e2}")
ligne_obj.DL_Design = ligne_data["designation"]
ligne_obj.DL_Qte = quantite
logger.debug(f" Configuration manuelle")
# ✅ APRÈS association : Désactiver le contrôle de stock
if ignorer_controle_stock:
# Méthode 1 : Copier DL_MvtStock depuis la source
try:
mvt_stock_source = ligne_data.get("mvt_stock", 0)
ligne_obj.DL_MvtStock = mvt_stock_source
logger.debug(
f" ✅ DL_MvtStock = {mvt_stock_source} (copié depuis source)"
)
except Exception as e:
logger.debug(f" ⚠️ DL_MvtStock: {e}")
# Méthode 2 : DL_NoStock = 1
try:
ligne_obj.DL_NoStock = 1
logger.debug(f" ✅ DL_NoStock = 1")
except Exception as e:
logger.debug(f" ⚠️ DL_NoStock: {e}")
# ✅ MÉTHODE 3 CRITIQUE : DL_NonLivre = quantité
# Indique que la quantité n'est PAS livrée, donc pas de sortie de stock
try:
ligne_obj.DL_NonLivre = quantite
logger.debug(
f" ✅ DL_NonLivre = {quantite} (évite sortie stock)"
)
except Exception as e:
logger.debug(f" ⚠️ DL_NonLivre: {e}")
# Prix unitaire
if prix > 0:
ligne_obj.DL_PrixUnitaire = float(prix)
logger.debug(f" Prix forcé: {prix}")
# Remise
remise = ligne_data["remise"]
if remise > 0:
try:
ligne_obj.DL_Remise01REM_Valeur = float(remise)
ligne_obj.DL_Remise01REM_Type = ligne_data[
"type_remise"
]
logger.debug(f" Remise: {remise}%")
except:
pass
# Écrire la ligne
try:
ligne_obj.Write()
lignes_creees += 1
logger.debug(f" ✅ Ligne {idx} écrite")
except Exception as e:
logger.error(f" ❌ Erreur écriture ligne {idx}: {e}")
# Lire erreurs
erreurs_ligne = lire_erreurs_sage(ligne_obj, f"Ligne_{idx}")
for err in erreurs_ligne:
logger.error(
f" {err['field']}: {err['description']}"
)
continue
logger.info(f"[TRANSFORM] {lignes_creees} lignes créées")
if lignes_creees == 0:
raise ValueError("Aucune ligne n'a pu être créée")
# ========================================
# ÉTAPE 9 : WRITE FINAL
# ========================================
logger.info("[TRANSFORM] Write() final avant Process()...")
doc_cible.Write()
# ========================================
# ÉTAPE 10 : PROCESS()
# ========================================
logger.info("[TRANSFORM] Appel Process()...")
try:
process.Process()
logger.info("[TRANSFORM] Process() réussi !")
except Exception as e:
logger.error(f"[TRANSFORM] ERREUR Process(): {e}")
toutes_erreurs = []
# Erreurs du process
erreurs_process = lire_erreurs_sage(process, "Process")
if erreurs_process:
logger.error(
f"[TRANSFORM] {len(erreurs_process)} erreur(s) process:"
)
for err in erreurs_process:
logger.error(
f" {err['field']}: {err['description']} (code: {err['number']})"
)
toutes_erreurs.append(
f"{err['field']}: {err['description']}"
)
# Erreurs du document
erreurs_doc = lire_erreurs_sage(doc_cible, "Document")
if erreurs_doc:
logger.error(
f"[TRANSFORM] {len(erreurs_doc)} erreur(s) document:"
)
for err in erreurs_doc:
logger.error(
f" {err['field']}: {err['description']} (code: {err['number']})"
)
toutes_erreurs.append(
f"{err['field']}: {err['description']}"
)
# Construire message
if toutes_erreurs:
error_msg = (
f"Process() échoué: {' | '.join(toutes_erreurs)}"
)
else:
error_msg = f"Process() échoué: {str(e)}"
# Conseil stock
if "stock" in error_msg.lower() or "2881" in error_msg:
error_msg += " | CONSEIL: Vérifiez le stock ou créez le document manuellement dans Sage."
logger.error(f"[TRANSFORM] {error_msg}")
raise RuntimeError(error_msg)
# ========================================
# ÉTAPE 11 : RÉCUPÉRER NUMÉRO
# ========================================
numero_cible = None
total_ht_final = 0.0
total_ttc_final = 0.0
# DocumentResult
try:
doc_result = process.DocumentResult
if doc_result:
doc_result = win32com.client.CastTo(
doc_result, "IBODocumentVente3"
)
doc_result.Read()
numero_cible = getattr(doc_result, "DO_Piece", "")
total_ht_final = float(
getattr(doc_result, "DO_TotalHT", 0.0)
)
total_ttc_final = float(
getattr(doc_result, "DO_TotalTTC", 0.0)
)
logger.info(
f"[TRANSFORM] DocumentResult: {numero_cible}, {total_ht_final}€ HT"
)
except Exception as e:
logger.debug(f"[TRANSFORM] DocumentResult non disponible: {e}")
# Relire doc_cible
if not numero_cible:
try:
doc_cible.Read()
numero_cible = getattr(doc_cible, "DO_Piece", "")
total_ht_final = float(
getattr(doc_cible, "DO_TotalHT", 0.0)
)
total_ttc_final = float(
getattr(doc_cible, "DO_TotalTTC", 0.0)
)
logger.info(
f"[TRANSFORM] doc_cible relu: {numero_cible}, {total_ht_final}€ HT"
)
except:
pass
if not numero_cible:
raise RuntimeError("Numéro document cible vide après Process()")
logger.info(f"[TRANSFORM] Document cible créé: {numero_cible}")
logger.info(
f"[TRANSFORM] Totaux: {total_ht_final}€ HT / {total_ttc_final}€ TTC"
)
# ========================================
# ÉTAPE 12 : COMMIT
# ========================================
if transaction_active:
try:
self.cial.CptaApplication.CommitTrans()
logger.info("[TRANSFORM] Transaction committée")
except:
pass
time.sleep(1)
logger.info(
f"[TRANSFORM] ✅ SUCCÈS: {numero_source} ({type_source}) -> "
f"{numero_cible} ({type_cible}) - {lignes_creees} lignes"
)
return {
"success": True,
"document_source": numero_source,
"document_cible": numero_cible,
"nb_lignes": lignes_creees,
"total_ht": total_ht_final,
"total_ttc": total_ttc_final,
}
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
logger.error("[TRANSFORM] Transaction annulée (rollback)")
except:
pass
raise
except Exception as e:
logger.error(f"[TRANSFORM] ❌ ERREUR: {e}", exc_info=True)
raise RuntimeError(f"Echec transformation: {str(e)}")
def _find_document_in_list(self, numero, type_doc):
"""Cherche un document dans List() si ReadPiece échoue"""
try:
factory = self.cial.FactoryDocumentVente
index = 1
while index < 10000:
try:
persist = factory.List(index)
if persist is None:
break
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
if (
getattr(doc, "DO_Type", -1) == type_doc
and getattr(doc, "DO_Piece", "") == numero
):
logger.info(f"[TRANSFORM] Document trouve a l'index {index}")
return persist
index += 1
except:
index += 1
continue
return None
except Exception as e:
logger.error(f"[TRANSFORM] Erreur recherche document: {e}")
return None
def mettre_a_jour_champ_libre(self, doc_id, type_doc, nom_champ, valeur):
"""Mise à jour champ libre pour Universign ID"""
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
persist = factory.ReadPiece(type_doc, doc_id)
if persist:
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
try:
setattr(doc, f"DO_{nom_champ}", valeur)
doc.Write()
logger.debug(f"Champ libre {nom_champ} = {valeur} sur {doc_id}")
return True
except Exception as e:
logger.warning(f"Impossible de mettre à jour {nom_champ}: {e}")
except Exception as e:
logger.error(f"Erreur MAJ champ libre: {e}")
return False
def _lire_client_obj(self, code_client):
"""Retourne l'objet client Sage brut (pour remises)"""
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory = self.cial.CptaApplication.FactoryClient
persist = factory.ReadNumero(code_client)
if persist:
return self._cast_client(persist)
except:
pass
return None
def lire_contact_principal_client(self, code_client):
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory_client = self.cial.CptaApplication.FactoryClient
persist_client = factory_client.ReadNumero(code_client)
if not persist_client:
return None
client = self._cast_client(persist_client)
if not client:
return None
# Récupérer infos contact principal
contact_info = {
"client_code": code_client,
"client_intitule": getattr(client, "CT_Intitule", ""),
"email": None,
"nom": None,
"telephone": None,
}
# Email principal depuis Telecom
try:
telecom = getattr(client, "Telecom", None)
if telecom:
contact_info["email"] = getattr(telecom, "EMail", "")
contact_info["telephone"] = getattr(telecom, "Telephone", "")
except:
pass
# Nom du contact
try:
contact_info["nom"] = (
getattr(client, "CT_Contact", "")
or contact_info["client_intitule"]
)
except:
contact_info["nom"] = contact_info["client_intitule"]
return contact_info
except Exception as e:
logger.error(f"Erreur lecture contact client {code_client}: {e}")
return None
def mettre_a_jour_derniere_relance(self, doc_id, type_doc):
date_relance = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return self.mettre_a_jour_champ_libre(
doc_id, type_doc, "DerniereRelance", date_relance
)
def lister_tous_prospects(self, filtre=""):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = """
SELECT
CT_Num, CT_Intitule, CT_Adresse, CT_Ville,
CT_CodePostal, CT_Telephone, CT_EMail
FROM F_COMPTET
WHERE CT_Type = 0 AND CT_Prospect = 1
"""
params = []
if filtre:
query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)"
params.extend([f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY CT_Intitule"
cursor.execute(query, params)
rows = cursor.fetchall()
prospects = []
for row in rows:
prospects.append(
{
"numero": self._safe_strip(row.CT_Num),
"intitule": self._safe_strip(row.CT_Intitule),
"adresse": self._safe_strip(row.CT_Adresse),
"ville": self._safe_strip(row.CT_Ville),
"code_postal": self._safe_strip(row.CT_CodePostal),
"telephone": self._safe_strip(row.CT_Telephone),
"email": self._safe_strip(row.CT_EMail),
"type": 0,
"est_prospect": True,
}
)
logger.info(f"✅ SQL: {len(prospects)} prospects")
return prospects
except Exception as e:
logger.error(f"❌ Erreur SQL prospects: {e}")
return []
def lire_prospect(self, code_prospect):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays,
CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie,
CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect,
CT_Contact, CT_FormeJuridique, CT_Secteur
FROM F_COMPTET
WHERE CT_Num = ? AND CT_Type = 0 AND CT_Prospect = 1
""",
(code_prospect.upper(),),
)
row = cursor.fetchone()
if not row:
return None
return {
"numero": self._safe_strip(row.CT_Num),
"intitule": self._safe_strip(row.CT_Intitule),
"type": 0,
"qualite": self._safe_strip(row.CT_Qualite),
"est_prospect": True,
"adresse": self._safe_strip(row.CT_Adresse),
"complement": self._safe_strip(row.CT_Complement),
"ville": self._safe_strip(row.CT_Ville),
"code_postal": self._safe_strip(row.CT_CodePostal),
"pays": self._safe_strip(row.CT_Pays),
"telephone": self._safe_strip(row.CT_Telephone),
"portable": self._safe_strip(row.CT_Portable),
"email": self._safe_strip(row.CT_EMail),
"telecopie": self._safe_strip(row.CT_Telecopie),
"siret": self._safe_strip(row.CT_Siret),
"tva_intra": self._safe_strip(row.CT_Identifiant),
"est_actif": (row.CT_Sommeil == 0),
"contact": self._safe_strip(row.CT_Contact),
"forme_juridique": self._safe_strip(row.CT_FormeJuridique),
"secteur": self._safe_strip(row.CT_Secteur),
}
except Exception as e:
logger.error(f"❌ Erreur SQL prospect {code_prospect}: {e}")
return None
def lister_avoirs(self, limit=100, statut=None):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = f"""
SELECT TOP ({limit})
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.CT_Num, c.CT_Intitule
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num
WHERE d.DO_Type = 50
"""
params = []
if statut is not None:
query += " AND d.DO_Statut = ?"
params.append(statut)
query += " ORDER BY d.DO_Date DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
avoirs = []
for row in rows:
avoirs.append(
{
"numero": self._safe_strip(row.DO_Piece),
"reference": self._safe_strip(row.DO_Ref),
"date": str(row.DO_Date) if row.DO_Date else "",
"client_code": self._safe_strip(row.CT_Num),
"client_intitule": self._safe_strip(row.CT_Intitule),
"total_ht": (
float(row.DO_TotalHT) if row.DO_TotalHT else 0.0
),
"total_ttc": (
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
),
"statut": row.DO_Statut if row.DO_Statut is not None else 0,
}
)
return avoirs
except Exception as e:
logger.error(f"❌ Erreur SQL avoirs: {e}")
return []
def lire_avoir(self, numero):
return self._lire_document_sql(numero, type_doc=50)
def lister_livraisons(self, limit=100, statut=None):
"""📖 Liste les livraisons via SQL (méthode legacy)"""
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = f"""
SELECT TOP ({limit})
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.CT_Num, c.CT_Intitule
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num
WHERE d.DO_Type = 30
"""
params = []
if statut is not None:
query += " AND d.DO_Statut = ?"
params.append(statut)
query += " ORDER BY d.DO_Date DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
livraisons = []
for row in rows:
livraisons.append(
{
"numero": self._safe_strip(row.DO_Piece),
"reference": self._safe_strip(row.DO_Ref),
"date": str(row.DO_Date) if row.DO_Date else "",
"client_code": self._safe_strip(row.CT_Num),
"client_intitule": self._safe_strip(row.CT_Intitule),
"total_ht": (
float(row.DO_TotalHT) if row.DO_TotalHT else 0.0
),
"total_ttc": (
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
),
"statut": row.DO_Statut if row.DO_Statut is not None else 0,
}
)
return livraisons
except Exception as e:
logger.error(f"❌ Erreur SQL livraisons: {e}")
return []
def lire_livraison(self, numero):
"""📖 Lit UNE livraison via SQL (avec lignes)"""
return self._lire_document_sql(numero, type_doc=30)
def creer_client(self, client_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
# ========================================
# ÉTAPE 0 : VALIDATION & NETTOYAGE
# ========================================
logger.info("🔍 === VALIDATION DES DONNÉES ===")
if not client_data.get("intitule"):
raise ValueError("Le champ 'intitule' est obligatoire")
# Nettoyage et troncature
intitule = str(client_data["intitule"])[:69].strip()
num_prop = (
str(client_data.get("num", "")).upper()[:17].strip()
if client_data.get("num")
else ""
)
compte = str(client_data.get("compte_collectif", "411000"))[:13].strip()
adresse = str(client_data.get("adresse", ""))[:35].strip()
code_postal = str(client_data.get("code_postal", ""))[:9].strip()
ville = str(client_data.get("ville", ""))[:35].strip()
pays = str(client_data.get("pays", ""))[:35].strip()
telephone = str(client_data.get("telephone", ""))[:21].strip()
email = str(client_data.get("email", ""))[:69].strip()
siret = str(client_data.get("siret", ""))[:14].strip()
tva_intra = str(client_data.get("tva_intra", ""))[:25].strip()
logger.info(f" intitule: '{intitule}' (len={len(intitule)})")
logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})")
logger.info(f" compte: '{compte}' (len={len(compte)})")
# ========================================
# ÉTAPE 1 : CRÉATION OBJET CLIENT
# ========================================
factory_client = self.cial.CptaApplication.FactoryClient
persist = factory_client.Create()
client = win32com.client.CastTo(persist, "IBOClient3")
# 🔑 CRITIQUE : Initialiser l'objet
client.SetDefault()
logger.info("✅ Objet client créé et initialisé")
# ========================================
# ÉTAPE 2 : CHAMPS OBLIGATOIRES (dans l'ordre !)
# ========================================
logger.info("📝 Définition des champs obligatoires...")
# 1. Intitulé (OBLIGATOIRE)
client.CT_Intitule = intitule
logger.debug(f" ✅ CT_Intitule: '{intitule}'")
# ❌ CT_Type SUPPRIMÉ (n'existe pas dans cette version)
# client.CT_Type = 0 # <-- LIGNE SUPPRIMÉE
# 2. Qualité (important pour filtrage Client/Fournisseur)
try:
client.CT_Qualite = "CLI"
logger.debug(" ✅ CT_Qualite: 'CLI'")
except:
logger.debug(" ⚠️ CT_Qualite non défini (pas critique)")
# 3. Compte général principal (OBLIGATOIRE)
try:
factory_compte = self.cial.CptaApplication.FactoryCompteG
persist_compte = factory_compte.ReadNumero(compte)
if persist_compte:
compte_obj = win32com.client.CastTo(
persist_compte, "IBOCompteG3"
)
compte_obj.Read()
# Assigner l'objet CompteG
client.CompteGPrinc = compte_obj
logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné")
else:
logger.warning(
f" ⚠️ Compte {compte} introuvable - utilisation du compte par défaut"
)
except Exception as e:
logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}")
# 4. Numéro client (OBLIGATOIRE - générer si vide)
if num_prop:
client.CT_Num = num_prop
logger.debug(f" ✅ CT_Num fourni: '{num_prop}'")
else:
# 🔑 CRITIQUE : Générer le numéro automatiquement
try:
# Méthode 1 : Utiliser SetDefaultNumPiece (si disponible)
if hasattr(client, "SetDefaultNumPiece"):
client.SetDefaultNumPiece()
num_genere = getattr(client, "CT_Num", "")
logger.debug(
f" ✅ CT_Num auto-généré (SetDefaultNumPiece): '{num_genere}'"
)
else:
# Méthode 2 : Lire le prochain numéro depuis la souche
factory_client = self.cial.CptaApplication.FactoryClient
num_genere = factory_client.GetNextNumero()
if num_genere:
client.CT_Num = num_genere
logger.debug(
f" ✅ CT_Num auto-généré (GetNextNumero): '{num_genere}'"
)
else:
# Méthode 3 : Fallback - utiliser un timestamp
import time
num_genere = f"CLI{int(time.time()) % 1000000}"
client.CT_Num = num_genere
logger.warning(
f" ⚠️ CT_Num fallback temporaire: '{num_genere}'"
)
except Exception as e:
logger.error(f" ❌ Impossible de générer CT_Num: {e}")
raise ValueError(
"Impossible de générer le numéro client automatiquement. Veuillez fournir un numéro manuellement."
)
# 5. Catégories tarifaires (valeurs par défaut)
try:
# Catégorie tarifaire (obligatoire)
if hasattr(client, "N_CatTarif"):
client.N_CatTarif = 1
# Catégorie comptable (obligatoire)
if hasattr(client, "N_CatCompta"):
client.N_CatCompta = 1
# Autres catégories
if hasattr(client, "N_Period"):
client.N_Period = 1
if hasattr(client, "N_Expedition"):
client.N_Expedition = 1
if hasattr(client, "N_Condition"):
client.N_Condition = 1
if hasattr(client, "N_Risque"):
client.N_Risque = 1
logger.debug(" ✅ Catégories (N_*) initialisées")
except Exception as e:
logger.warning(f" ⚠️ Catégories: {e}")
# ========================================
# ÉTAPE 3 : CHAMPS OPTIONNELS MAIS RECOMMANDÉS
# ========================================
logger.info("📝 Définition champs optionnels...")
# Adresse (objet IAdresse)
if any([adresse, code_postal, ville, pays]):
try:
adresse_obj = client.Adresse
if adresse:
adresse_obj.Adresse = adresse
if code_postal:
adresse_obj.CodePostal = code_postal
if ville:
adresse_obj.Ville = ville
if pays:
adresse_obj.Pays = pays
logger.debug(" ✅ Adresse définie")
except Exception as e:
logger.warning(f" ⚠️ Adresse: {e}")
# Télécom (objet ITelecom)
if telephone or email:
try:
telecom_obj = client.Telecom
if telephone:
telecom_obj.Telephone = telephone
if email:
telecom_obj.EMail = email
logger.debug(" ✅ Télécom défini")
except Exception as e:
logger.warning(f" ⚠️ Télécom: {e}")
# Identifiants fiscaux
if siret:
try:
client.CT_Siret = siret
logger.debug(f" ✅ SIRET: '{siret}'")
except Exception as e:
logger.warning(f" ⚠️ SIRET: {e}")
if tva_intra:
try:
client.CT_Identifiant = tva_intra
logger.debug(f" ✅ TVA intracommunautaire: '{tva_intra}'")
except Exception as e:
logger.warning(f" ⚠️ TVA: {e}")
# Autres champs utiles (valeurs par défaut intelligentes)
try:
# Type de facturation (1 = facture normale)
if hasattr(client, "CT_Facture"):
client.CT_Facture = 1
# Lettrage automatique activé
if hasattr(client, "CT_Lettrage"):
client.CT_Lettrage = True
# Pas de prospect
if hasattr(client, "CT_Prospect"):
client.CT_Prospect = False
# Client actif (pas en sommeil)
if hasattr(client, "CT_Sommeil"):
client.CT_Sommeil = False
logger.debug(" ✅ Options par défaut définies")
except Exception as e:
logger.debug(f" ⚠️ Options: {e}")
# ========================================
# ÉTAPE 4 : DIAGNOSTIC PRÉ-WRITE (pour debug)
# ========================================
logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===")
champs_critiques = [
("CT_Intitule", "str"),
("CT_Num", "str"),
("CompteGPrinc", "object"),
("N_CatTarif", "int"),
("N_CatCompta", "int"),
]
for champ, type_attendu in champs_critiques:
try:
val = getattr(client, champ, None)
if type_attendu == "object":
status = "✅ Objet défini" if val else "❌ NULL"
else:
if type_attendu == "str":
status = (
f"'{val}' (len={len(val)})" if val else "❌ Vide"
)
else:
status = f"{val}"
logger.info(f" {champ}: {status}")
except Exception as e:
logger.error(f" {champ}: ❌ Erreur - {e}")
# ========================================
# ÉTAPE 5 : VÉRIFICATION FINALE CT_Num
# ========================================
num_avant_write = getattr(client, "CT_Num", "")
if not num_avant_write:
logger.error(
"❌ CRITIQUE: CT_Num toujours vide après toutes les tentatives !"
)
raise ValueError(
"Le numéro client (CT_Num) est obligatoire mais n'a pas pu être généré. "
"Veuillez fournir un numéro manuellement via le paramètre 'num'."
)
logger.info(f"✅ CT_Num confirmé avant Write(): '{num_avant_write}'")
# ========================================
# ÉTAPE 6 : ÉCRITURE EN BASE
# ========================================
logger.info("💾 Écriture du client dans Sage...")
try:
client.Write()
logger.info("✅ Write() réussi !")
except Exception as e:
error_detail = str(e)
# Récupérer l'erreur Sage détaillée
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = (
f"{sage_error.Description} (Code: {sage_error.Number})"
)
logger.error(f"❌ Erreur Sage: {error_detail}")
except:
pass
# Analyser l'erreur spécifique
if "longueur invalide" in error_detail.lower():
logger.error("❌ ERREUR 'longueur invalide' - Dump des champs:")
for attr in dir(client):
if attr.startswith("CT_") or attr.startswith("N_"):
try:
val = getattr(client, attr, None)
if isinstance(val, str):
logger.error(
f" {attr}: '{val}' (len={len(val)})"
)
elif val is not None and not callable(val):
logger.error(
f" {attr}: {val} (type={type(val).__name__})"
)
except:
pass
if (
"doublon" in error_detail.lower()
or "existe" in error_detail.lower()
):
raise ValueError(f"Ce client existe déjà : {error_detail}")
raise RuntimeError(f"Échec Write(): {error_detail}")
# ========================================
# ÉTAPE 7 : RELECTURE & FINALISATION
# ========================================
try:
client.Read()
except Exception as e:
logger.warning(f"⚠️ Impossible de relire: {e}")
num_final = getattr(client, "CT_Num", "")
if not num_final:
raise RuntimeError("CT_Num vide après Write()")
logger.info(f"✅✅✅ CLIENT CRÉÉ: {num_final} - {intitule} ✅✅✅")
# ========================================
# ÉTAPE 8 : REFRESH CACHE
# ========================================
return {
"numero": num_final,
"intitule": intitule,
"compte_collectif": compte,
"type": 0, # Par défaut client
"adresse": adresse or None,
"code_postal": code_postal or None,
"ville": ville or None,
"pays": pays or None,
"email": email or None,
"telephone": telephone or None,
"siret": siret or None,
"tva_intra": tva_intra or None,
}
except ValueError as e:
logger.error(f"❌ Erreur métier: {e}")
raise
except Exception as e:
logger.error(f"❌ Erreur création client: {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage: {error_message}")
def modifier_client(self, code: str, client_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
# ========================================
# ÉTAPE 1 : CHARGER LE CLIENT EXISTANT
# ========================================
logger.info(f"🔍 Recherche client {code}...")
factory_client = self.cial.CptaApplication.FactoryClient
persist = factory_client.ReadNumero(code)
if not persist:
raise ValueError(f"Client {code} introuvable")
client = win32com.client.CastTo(persist, "IBOClient3")
client.Read()
logger.info(
f"✅ Client {code} trouvé: {getattr(client, 'CT_Intitule', '')}"
)
# ========================================
# ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS
# ========================================
logger.info("📝 Mise à jour des champs...")
champs_modifies = []
# Intitulé
if "intitule" in client_data:
intitule = str(client_data["intitule"])[:69].strip()
client.CT_Intitule = intitule
champs_modifies.append(f"intitule='{intitule}'")
# Adresse
if any(
k in client_data
for k in ["adresse", "code_postal", "ville", "pays"]
):
try:
adresse_obj = client.Adresse
if "adresse" in client_data:
adresse = str(client_data["adresse"])[:35].strip()
adresse_obj.Adresse = adresse
champs_modifies.append("adresse")
if "code_postal" in client_data:
cp = str(client_data["code_postal"])[:9].strip()
adresse_obj.CodePostal = cp
champs_modifies.append("code_postal")
if "ville" in client_data:
ville = str(client_data["ville"])[:35].strip()
adresse_obj.Ville = ville
champs_modifies.append("ville")
if "pays" in client_data:
pays = str(client_data["pays"])[:35].strip()
adresse_obj.Pays = pays
champs_modifies.append("pays")
except Exception as e:
logger.warning(f"⚠️ Erreur mise à jour adresse: {e}")
# Télécom
if "email" in client_data or "telephone" in client_data:
try:
telecom_obj = client.Telecom
if "email" in client_data:
email = str(client_data["email"])[:69].strip()
telecom_obj.EMail = email
champs_modifies.append("email")
if "telephone" in client_data:
tel = str(client_data["telephone"])[:21].strip()
telecom_obj.Telephone = tel
champs_modifies.append("telephone")
except Exception as e:
logger.warning(f"⚠️ Erreur mise à jour télécom: {e}")
# SIRET
if "siret" in client_data:
try:
siret = str(client_data["siret"])[:14].strip()
client.CT_Siret = siret
champs_modifies.append("siret")
except Exception as e:
logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}")
# TVA Intracommunautaire
if "tva_intra" in client_data:
try:
tva = str(client_data["tva_intra"])[:25].strip()
client.CT_Identifiant = tva
champs_modifies.append("tva_intra")
except Exception as e:
logger.warning(f"⚠️ Erreur mise à jour TVA: {e}")
if not champs_modifies:
logger.warning("⚠️ Aucun champ à modifier")
return self._extraire_client(client)
logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}")
# ========================================
# ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS
# ========================================
logger.info("💾 Écriture des modifications...")
try:
client.Write()
logger.info("✅ Write() réussi !")
except Exception as e:
error_detail = str(e)
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = (
f"{sage_error.Description} (Code: {sage_error.Number})"
)
except:
pass
logger.error(f"❌ Erreur Write(): {error_detail}")
raise RuntimeError(f"Échec modification: {error_detail}")
# ========================================
# ÉTAPE 4 : RELIRE ET RETOURNER
# ========================================
client.Read()
logger.info(
f"✅✅✅ CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅"
)
# Refresh cache
return self._extraire_client(client)
except ValueError as e:
logger.error(f"❌ Erreur métier: {e}")
raise
except Exception as e:
logger.error(f"❌ Erreur modification client: {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage: {error_message}")
def modifier_devis(self, numero: str, devis_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
# ÉTAPE 1 : CHARGER LE DEVIS
logger.info(f"🔍 Recherche devis {numero}...")
factory = self.cial.FactoryDocumentVente
persist = factory.ReadPiece(0, numero)
if not persist:
index = 1
while index < 10000:
try:
persist_test = factory.List(index)
if persist_test is None:
break
doc_test = win32com.client.CastTo(
persist_test, "IBODocumentVente3"
)
doc_test.Read()
if (
getattr(doc_test, "DO_Type", -1) == 0
and getattr(doc_test, "DO_Piece", "") == numero
):
persist = persist_test
break
index += 1
except:
index += 1
if not persist:
raise ValueError(f"Devis {numero} introuvable")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
logger.info(f"✅ Devis {numero} trouvé")
# Vérifier transformation
verification = self.verifier_si_deja_transforme(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 generer_pdf_document(self, numero: str, type_doc: int) -> bytes:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
logger.info(f"📄 Génération PDF: {numero} (type={type_doc})")
# ========================================
# ÉTAPE 1 : CHARGER LE DOCUMENT
# ========================================
factory = self.cial.FactoryDocumentVente
# Essayer ReadPiece
persist = factory.ReadPiece(type_doc, numero)
if not persist:
# Fallback: chercher dans List()
logger.debug(f"ReadPiece échoué, recherche dans List()...")
persist = self._find_document_in_list(numero, type_doc)
if not persist:
raise ValueError(f"Document {numero} (type {type_doc}) introuvable")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
logger.info(f"✅ Document chargé: {numero}")
# ========================================
# ÉTAPE 2 : IDENTIFIER L'ÉTAT CRYSTAL
# ========================================
# Mapping des types vers les noms d'états Sage
etats_sage = {
0: "VT_DEVIS.RPT", # Devis
10: "VT_CMDE.RPT", # Bon de commande
20: "VT_PREP.RPT", # Préparation
30: "VT_BL.RPT", # Bon de livraison
40: "VT_BR.RPT", # Bon de retour
50: "VT_AVOIR.RPT", # Bon d'avoir
60: "VT_FACT.RPT", # Facture
}
etat_nom = etats_sage.get(type_doc)
if not etat_nom:
raise ValueError(f"Type de document non supporté: {type_doc}")
logger.info(f"📋 État Sage: {etat_nom}")
# ========================================
# ÉTAPE 3 : GÉNÉRER LE PDF VIA L'ÉTAT
# ========================================
try:
# Accéder au gestionnaire d'états
factory_etat = self.cial.FactoryEtat
# Charger l'état
etat = factory_etat.ReadNom(etat_nom)
if not etat:
raise RuntimeError(f"État {etat_nom} non trouvé dans Sage")
# Paramétrer l'état
etat.Destination = 6 # 6 = PDF
etat.Preview = False # Pas de prévisualisation
# Définir le fichier de sortie temporaire
import tempfile
import os
temp_dir = tempfile.gettempdir()
pdf_filename = f"sage_pdf_{numero}_{int(time.time())}.pdf"
pdf_path = os.path.join(temp_dir, pdf_filename)
etat.FileName = pdf_path
# Définir le filtre (seulement ce document)
etat.Selection = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'"
logger.info(f"📁 Fichier temporaire: {pdf_path}")
# Exécuter l'état
logger.info("🔄 Exécution état Crystal...")
etat.Start()
# Attendre que le fichier soit créé
max_wait = 30 # 30 secondes max
waited = 0
while not os.path.exists(pdf_path) and waited < max_wait:
time.sleep(0.5)
waited += 0.5
if not os.path.exists(pdf_path):
raise RuntimeError(
f"Le fichier PDF n'a pas été créé après {max_wait}s"
)
# Lire le fichier PDF
with open(pdf_path, "rb") as f:
pdf_bytes = f.read()
logger.info(f"✅ PDF lu: {len(pdf_bytes)} octets")
# Nettoyer le fichier temporaire
try:
os.remove(pdf_path)
logger.debug(f"🗑️ Fichier temporaire supprimé")
except:
pass
if len(pdf_bytes) == 0:
raise RuntimeError("Le PDF généré est vide")
logger.info(
f"✅✅✅ PDF GÉNÉRÉ: {numero} ({len(pdf_bytes)} octets) ✅✅✅"
)
return pdf_bytes
except Exception as e:
logger.error(f"❌ Erreur génération état: {e}", exc_info=True)
raise RuntimeError(f"Erreur génération PDF: {str(e)}")
except ValueError as e:
logger.error(f"❌ Erreur métier: {e}")
raise
except Exception as e:
logger.error(f"❌ Erreur technique: {e}", exc_info=True)
raise RuntimeError(f"Erreur Sage: {str(e)}")
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]
logger.info(f"[ARTICLE] Référence : {reference}")
logger.info(f"[ARTICLE] Désignation : {designation}")
# ========================================
# É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 (RAPIDE)
# ========================================
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 UNITE + FAMILLE (OBLIGATOIRES !)
# ========================================
logger.info("[OBJETS] Copie Unite + Famille depuis modèle...")
# Unite (obligatoire)
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 (OBLIGATOIRE) - VERSION OPTIMISÉE SQL + SCANNER
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:
# ========================================
# 🚀 ÉTAPE 1 : VÉRIFIER EXISTENCE VIA SQL (ULTRA-RAPIDE)
# ========================================
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_personnalise.upper(),),
)
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_personnalise}' est de type 'Total' "
f"(agrégation comptable) et ne peut pas contenir d'articles.\n\n"
f"Utilisez plutôt une sous-famille de détail."
)
logger.info(
f" [SQL] Famille trouvée : {famille_code_exact} (type={famille_type})"
)
else:
logger.warning(
f" [SQL] Famille '{famille_code_personnalise}' introuvable"
)
except Exception as e_sql:
logger.warning(f" [SQL] Erreur : {e_sql}")
# ========================================
# 🚀 ÉTAPE 2 : SI EXISTE EN SQL, CHARGER VIA COM (SCANNER)
# ========================================
if famille_existe_sql and famille_code_exact:
logger.info(
f" [COM] Recherche de '{famille_code_exact}' via scanner..."
)
factory_famille = self.cial.FactoryFamille
# ✅ Scanner List() (compatible Sage v12 - IBOFamilleFactory2)
try:
index = 1
max_scan = 1000
while index <= max_scan:
try:
persist_test = factory_famille.List(index)
if persist_test is None:
break
# Cast et lecture
fam_test = win32com.client.CastTo(
persist_test, "IBOFamille3"
)
fam_test.Read()
# Comparer les codes
code_test = (
getattr(fam_test, "FA_CodeFamille", "")
.strip()
.upper()
)
if code_test == famille_code_exact.upper():
# ✅ TROUVÉ !
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
) or "Access" in str(e):
break
index += 1
if not famille_trouvee:
logger.warning(
f" [COM] Famille '{famille_code_exact}' non trouvée après scan de {index-1} familles"
)
except Exception as e:
logger.warning(
f" [COM] Scanner échoué : {str(e)[:200]}"
)
# ✅ ASSIGNER LA FAMILLE SI TROUVÉE
if famille_obj:
# ✅ CRITIQUE : Re-lire juste avant assignation
famille_obj.Read()
article.Famille = famille_obj
logger.info(
f" [OK] Famille '{famille_code_personnalise}' assignée"
)
else:
# ❌ FAMILLE INTROUVABLE VIA COM
logger.error(
f" [ERREUR] Famille '{famille_code_personnalise}' trouvée en SQL mais inaccessible via COM"
)
# Lister les familles disponibles
familles_disponibles = []
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT TOP 30 FA_CodeFamille, FA_Intitule
FROM F_FAMILLE
WHERE FA_Type = 0
ORDER BY FA_CodeFamille
"""
)
rows = cursor.fetchall()
for row in rows:
code = self._safe_strip(
row.FA_CodeFamille
)
intitule = self._safe_strip(
row.FA_Intitule
)
if code:
familles_disponibles.append(
f"{code} - {intitule}"
)
except:
pass
msg_erreur = f"Famille '{famille_code_personnalise}' trouvée en SQL mais inaccessible via COM."
if familles_disponibles:
msg_erreur += (
f"\n\nFamilles de DÉTAIL disponibles :\n"
+ "\n".join(familles_disponibles)
)
msg_erreur += "\n\nSolution : Essayez avec ZDIVERS ou créez une nouvelle famille"
raise ValueError(msg_erreur)
else:
# Famille pas trouvée en SQL
familles_disponibles = []
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT TOP 30 FA_CodeFamille, FA_Intitule
FROM F_FAMILLE
WHERE FA_Type = 0
ORDER BY FA_CodeFamille
"""
)
rows = cursor.fetchall()
for row in rows:
code = self._safe_strip(row.FA_CodeFamille)
intitule = self._safe_strip(row.FA_Intitule)
if code:
familles_disponibles.append(
f"{code} - {intitule}"
)
except:
pass
msg_erreur = f"Famille '{famille_code_personnalise}' introuvable dans Sage."
if familles_disponibles:
msg_erreur += (
f"\n\nFamilles disponibles :\n"
+ "\n".join(familles_disponibles)
)
else:
msg_erreur += "\n\nAucune famille trouvée. Créez d'abord des familles dans Sage."
raise ValueError(msg_erreur)
except ValueError:
raise # Re-raise si c'est notre erreur de validation
except Exception as e:
logger.warning(
f" [WARN] Famille personnalisée non chargée : {str(e)[:100]}"
)
# Si pas de famille perso OU si échec, 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]}")
if not famille_trouvee:
logger.warning(
" ⚠️ Aucune famille assignée - risque d'erreur cohérence"
)
# ========================================
# ÉTAPE 6 : CHAMPS OBLIGATOIRES (ORDRE CRITIQUE)
# ========================================
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)")
# Champs standards
article.AR_Coef = float(getattr(article_modele, "AR_Coef", 2.0))
article.AR_Garantie = int(
getattr(article_modele, "AR_Garantie", 12)
)
article.AR_UnitePoids = int(
getattr(article_modele, "AR_UnitePoids", 3)
)
article.AR_Sommeil = False
# Champs de gestion
article.AR_Cycle = int(getattr(article_modele, "AR_Cycle", 1))
article.AR_Delai = int(getattr(article_modele, "AR_Delai", 0))
article.AR_DelaiFabrication = int(
getattr(article_modele, "AR_DelaiFabrication", 0)
)
article.AR_Criticite = int(
getattr(article_modele, "AR_Criticite", 0)
)
# Options booléennes
article.AR_HorsStat = bool(
getattr(article_modele, "AR_HorsStat", False)
)
article.AR_Escompte = bool(
getattr(article_modele, "AR_Escompte", False)
)
article.AR_PrixTTC = bool(
getattr(article_modele, "AR_PrixTTC", False)
)
article.AR_VteDebit = bool(
getattr(article_modele, "AR_VteDebit", False)
)
article.AR_NotImp = bool(
getattr(article_modele, "AR_NotImp", False)
)
article.AR_Exclure = bool(
getattr(article_modele, "AR_Exclure", False)
)
logger.info(f" [OK] Tous les champs obligatoires copiés")
# ========================================
# É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:
# ⚠️ CORRECTION : Tester les deux noms possibles
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-barres principal)
# ========================================
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 : STOCK MINI/MAXI (NIVEAU ARTICLE)
# ========================================
stock_mini = article_data.get("stock_mini")
stock_maxi = article_data.get("stock_maxi")
# ✅ NOUVEAU : Définir au niveau ARTICLE (global)
if stock_mini is not None:
try:
article.AR_StockMini = float(stock_mini)
logger.info(f" AR_StockMini (article) : {stock_mini}")
except Exception as e:
logger.warning(f" AR_StockMini erreur : {e}")
if stock_maxi is not None:
try:
article.AR_StockMaxi = float(stock_maxi)
logger.info(f" AR_StockMaxi (article) : {stock_maxi}")
except Exception as e:
logger.warning(f" AR_StockMaxi erreur : {e}")
# ========================================
# ÉTAPE 11 : É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 12 : DÉFINIR LE STOCK (NIVEAU DÉPÔT)
# ========================================
stock_reel = article_data.get("stock_reel")
stock_defini = False
stock_erreur = None
if stock_reel and stock_reel > 0:
logger.info(
f"[STOCK] Définition stock : {stock_reel} unités sur dépôt '{depot_a_utiliser['code']}'"
)
try:
depot_obj = depot_a_utiliser["objet"]
factory_depot_stock = None
for factory_name in [
"FactoryDepotStock",
"FactoryArticleStock",
]:
try:
factory_depot_stock = getattr(
depot_obj, factory_name, None
)
if factory_depot_stock:
logger.info(
f" Factory trouvée : {factory_name}"
)
break
except:
continue
if not factory_depot_stock:
raise RuntimeError(
"FactoryDepotStock introuvable sur le dépôt"
)
stock_persist = factory_depot_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
stock_obj.AS_QteSto = float(stock_reel)
logger.info(f" AS_QteSto = {stock_reel}")
# ✅ Stock minimum (niveau dépôt)
if stock_mini is not None:
try:
stock_obj.AS_QteMin = float(stock_mini)
logger.info(f" AS_QteMin (dépôt) = {stock_mini}")
except Exception as e:
logger.warning(f" AS_QteMin non défini : {e}")
# ✅ Stock maximum (niveau dépôt)
if stock_maxi is not None:
try:
stock_obj.AS_QteMax = float(stock_maxi)
logger.info(f" AS_QteMax (dépôt) = {stock_maxi}")
except Exception as e:
logger.warning(f" AS_QteMax non défini : {e}")
stock_obj.Write()
stock_defini = True
logger.info(
f" [OK] Stock défini : {stock_reel} unités (min={stock_mini}, max={stock_maxi})"
)
except Exception as e:
stock_erreur = str(e)
logger.error(
f" [ERREUR] Stock non défini : {e}", exc_info=True
)
# Gérer stock_mini/maxi SANS stock_reel
elif stock_mini is not None or stock_maxi is not None:
logger.info(
f"[STOCK] Définition stock_mini/maxi sans stock_reel..."
)
try:
depot_obj = depot_a_utiliser["objet"]
factory_depot_stock = None
for factory_name in [
"FactoryDepotStock",
"FactoryArticleStock",
]:
try:
factory_depot_stock = getattr(
depot_obj, factory_name, None
)
if factory_depot_stock:
break
except:
pass
if factory_depot_stock:
stock_persist = factory_depot_stock.Create()
stock_obj = win32com.client.CastTo(
stock_persist, "IBODepotStock3"
)
stock_obj.SetDefault()
stock_obj.AR_Ref = reference
stock_obj.AS_QteSto = 0.0 # Stock réel à 0
if stock_mini is not None:
try:
stock_obj.AS_QteMin = float(stock_mini)
logger.info(f" AS_QteMin = {stock_mini}")
except:
pass
if stock_maxi is not None:
try:
stock_obj.AS_QteMax = float(stock_maxi)
logger.info(f" AS_QteMax = {stock_maxi}")
except:
pass
stock_obj.Write()
stock_defini = True
logger.info(
f" [OK] Stock min/max défini sans stock réel"
)
except Exception as e:
logger.warning(f" [WARN] Stock min/max non défini : {e}")
# ========================================
# ÉTAPE 13 : COMMIT (CRITIQUE POUR PERSISTANCE)
# ========================================
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 14 : 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 15 : LIRE LES STOCKS PAR DÉPÔT
# ========================================
stocks_par_depot = []
stock_total = 0.0
for depot_info in depots_disponibles:
try:
depot_obj = depot_info["objet"]
factory_depot_stock = getattr(
depot_obj, "FactoryDepotStock", None
)
if factory_depot_stock:
index = 1
while index <= 1000:
try:
stock_persist = factory_depot_stock.List(index)
if stock_persist is None:
break
stock = win32com.client.CastTo(
stock_persist, "IBODepotStock3"
)
stock.Read()
article_ref_stock = getattr(
stock, "AR_Ref", ""
).strip()
if article_ref_stock == reference:
qte = float(
getattr(stock, "AS_QteSto", 0.0)
)
stock_total += qte
stocks_par_depot.append(
{
"depot_code": depot_info["code"],
"depot_intitule": depot_info[
"intitule"
],
"quantite": qte,
"qte_mini": float(
getattr(stock, "AS_QteMin", 0.0)
),
"qte_maxi": float(
getattr(stock, "AS_QteMax", 0.0)
),
}
)
break
index += 1
except Exception as e:
if "Acces refuse" in str(e):
break
index += 1
except:
pass
logger.info(
f"[OK] ARTICLE CREE : {reference} - Stock total : {stock_total}"
)
# ========================================
# ÉTAPE 16 : EXTRACTION COMPLÈTE + ENRICHISSEMENT
# ========================================
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 _extraire_article échoue
resultat = {
"reference": reference,
"designation": designation,
}
# ========================================
# ENRICHIR AVEC LES VALEURS QU'ON A DÉFINIES
# ========================================
# ⚠️ IMPORTANT : Certaines valeurs ne sont pas encore relues correctement
# juste après le Write(), donc on force les valeurs qu'on a définies
# ✅ 1. PRIX (forcer les valeurs définies)
if prix_vente is not None:
resultat["prix_vente"] = float(prix_vente)
if prix_achat is not None:
resultat["prix_achat"] = float(prix_achat)
# ✅ 2. STOCK (utiliser le calcul depuis les dépôts, plus fiable)
resultat["stock_reel"] = stock_total
if stock_mini is not None:
resultat["stock_mini"] = float(stock_mini)
if stock_maxi is not None:
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
# ✅ 3. DESCRIPTION (forcer la valeur définie)
if description:
resultat["description"] = description
# ✅ 4. CODE EAN (forcer la valeur définie)
if code_ean:
resultat["code_ean"] = str(code_ean)
resultat["code_barre"] = str(
code_ean
) # Identique (code-barres principal)
# ✅ 5. FAMILLE (si personnalisée, forcer le code et libellé)
if famille_code_personnalise and famille_trouvee:
resultat["famille_code"] = famille_code_personnalise
# Essayer de récupérer le libellé
try:
if famille_obj:
famille_obj.Read()
resultat["famille_libelle"] = getattr(
famille_obj, "FA_Intitule", ""
)
except:
pass
# ✅ 6. DATES (forcer date actuelle pour cohérence)
from datetime import datetime
date_maintenant = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
resultat["date_creation"] = date_maintenant
resultat["date_modification"] = date_maintenant
# ✅ 7. INFOS DÉPÔTS
resultat["stocks_par_depot"] = stocks_par_depot
resultat["depot_principal"] = {
"code": depot_a_utiliser["code"],
"intitule": depot_a_utiliser["intitule"],
}
# ✅ 8. SUIVI DE STOCK
resultat["suivi_stock_active"] = stock_defini
# ✅ 9. AVERTISSEMENT SI STOCK NON DÉFINI
if stock_reel and not stock_defini:
resultat["avertissement"] = (
f"Stock demandé ({stock_reel}) mais non défini : {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()
# ========================================
# ÉTAPE 1 : DÉTECTER LES COLONNES DISPONIBLES
# ========================================
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)}")
# ========================================
# ÉTAPE 2 : DÉFINIR LES COLONNES PRIORITAIRES
# ========================================
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)}")
# ========================================
# ÉTAPE 3 : CONSTRUIRE LA REQUÊTE AVEC FILTRE TYPE
# ========================================
colonnes_str = ", ".join(colonnes_a_lire)
query = f"""
SELECT {colonnes_str}
FROM F_FAMILLE
WHERE 1=1
"""
params = []
# ✅ CRITIQUE : Filtrer par type (défaut = seulement Détail)
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"
# ========================================
# ÉTAPE 4 : EXÉCUTER LA REQUÊTE
# ========================================
cursor.execute(query, params)
rows = cursor.fetchall()
# ========================================
# ÉTAPE 5 : CONSTRUCTION DE LA LISTE
# ========================================
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
# ========================================
# CHAMPS CALCULÉS & ALIAS
# ========================================
# 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"] = 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()
# ========================================
# ÉTAPE 1 : VÉRIFICATION SQL (RAPIDE)
# ========================================
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
# ========================================
# ÉTAPE 2 : CHARGEMENT VIA COM (SCANNER)
# ========================================
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)}")
# ========================================
# ÉTAPE 3 : EXTRACTION COMPLÈTE
# ========================================
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"] = ""
# ========================================
# INFORMATIONS TECHNIQUES
# ========================================
# 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"] = ""
# ========================================
# STATISTIQUES (NOMBRE D'ARTICLES)
# ========================================
# 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 ===")
logger.info(f"[STOCK] {len(entree_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(180) # 180 = Entrée
doc = win32com.client.CastTo(persist, "IBODocumentStock3")
doc.SetDefault()
# Date
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())
# Référence
if entree_data.get("reference"):
doc.DO_Ref = entree_data["reference"]
doc.Write()
logger.info(
f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}"
)
# ========================================
# ÉTAPE 2 : FACTORY LIGNES
# ========================================
try:
factory_lignes = doc.FactoryDocumentLigne
logger.info(f"[STOCK] Factory lignes : FactoryDocumentLigne")
except:
factory_lignes = doc.FactoryDocumentStockLigne
logger.info(
f"[STOCK] Factory lignes : FactoryDocumentStockLigne"
)
factory_article = self.cial.FactoryArticle
stocks_mis_a_jour = []
# ========================================
# ÉTAPE 3 : TRAITER CHAQUE LIGNE
# ========================================
for idx, ligne_data in enumerate(entree_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} ({'CMUP' if ar_suivi == 1 else 'FIFO/LIFO' if ar_suivi == 2 else 'Aucun'})"
)
# Gérer le lot selon le mode de suivi
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}'"
)
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()
# Cast selon le type disponible
try:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
logger.info(f"[STOCK] Cast : IBODocumentLigne3")
except:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentStockLigne3"
)
logger.info(f"[STOCK] Cast : IBODocumentStockLigne3")
ligne_obj.SetDefault()
# ========================================
# LIAISON ARTICLE + QUANTITÉ
# ========================================
article_lie = False
methode_utilisee = None
# MÉTHODE 1 : SetDefaultArticleReference()
try:
logger.info(
f"[STOCK] SetDefaultArticleReference('{article_ref}', {quantite})..."
)
ligne_obj.SetDefaultArticleReference(
article_ref, float(quantite)
)
article_lie = True
methode_utilisee = "SetDefaultArticleReference"
logger.info(
f"[STOCK] ✅ Article lié via SetDefaultArticleReference()"
)
except Exception as e1:
logger.warning(
f"[STOCK] SetDefaultArticleReference échoué : {str(e1)[:150]}"
)
# MÉTHODE 2 : SetDefaultArticle()
if not article_lie:
try:
logger.info(
f"[STOCK] SetDefaultArticle(article_obj, {quantite})..."
)
ligne_obj.SetDefaultArticle(
article_obj, float(quantite)
)
article_lie = True
methode_utilisee = "SetDefaultArticle"
logger.info(
f"[STOCK] ✅ Article lié via SetDefaultArticle()"
)
except Exception as e2:
logger.warning(
f"[STOCK] SetDefaultArticle échoué : {str(e2)[:150]}"
)
if not article_lie:
raise ValueError(
f"Impossible de lier l'article {article_ref}"
)
# ========================================
# DÉFINIR LE LOT (si FIFO/LIFO)
# ========================================
if numero_lot and ar_suivi == 2:
logger.info(f"[STOCK] Définition du lot '{numero_lot}'...")
try:
# MÉTHODE 1 : SetDefaultLot()
ligne_obj.SetDefaultLot(numero_lot)
logger.info(
f"[STOCK] ✅ Lot défini via SetDefaultLot()"
)
except Exception as e_lot1:
logger.warning(
f"[STOCK] SetDefaultLot échoué : {str(e_lot1)[:150]}"
)
# MÉTHODE 2 : Attribut LS_NoSerie
try:
ligne_obj.LS_NoSerie = numero_lot
logger.info(f"[STOCK] ✅ Lot défini via LS_NoSerie")
except Exception as e_lot2:
logger.warning(
f"[STOCK] LS_NoSerie échoué : {str(e_lot2)[:150]}"
)
# ========================================
# PRIX UNITAIRE
# ========================================
prix = ligne_data.get("prix_unitaire")
if prix:
try:
ligne_obj.DL_PrixUnitaire = float(prix)
logger.info(f"[STOCK] Prix unitaire : {prix}")
except:
pass
# ========================================
# ÉCRIRE LA LIGNE
# ========================================
logger.info(f"[STOCK] Appel Write()...")
ligne_obj.Write()
logger.info(f"[STOCK] ✅ Write() réussi")
# ========================================
# VÉRIFICATION (via Article.AR_Ref, pas AR_Ref direct)
# ========================================
logger.info(f"[STOCK] Vérification...")
ligne_obj.Read()
ref_verifiee = None
# Vérifier via l'objet Article (objet COM)
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()
if ref_verifiee:
logger.info(
f"[STOCK] ✅ Référence vérifiée via Article.AR_Ref : {ref_verifiee}"
)
except Exception as e_verif:
logger.warning(
f"[STOCK] Impossible de vérifier via Article : {e_verif}"
)
# Si pas de vérification possible, considérer comme OK si Write() a réussi
if not ref_verifiee:
logger.warning(
f"[STOCK] ⚠️ Impossible de vérifier la référence, mais Write() a réussi"
)
ref_verifiee = article_ref # Supposer que c'est OK
logger.info(f"[STOCK] ✅ LIGNE {idx} COMPLÈTE")
stocks_mis_a_jour.append(
{
"article_ref": article_ref,
"quantite_ajoutee": quantite,
"methode_liaison": methode_utilisee,
"reference_verifiee": ref_verifiee,
"numero_lot": numero_lot if ar_suivi == 2 else None,
}
)
# ========================================
# FINALISER LE DOCUMENT
# ========================================
logger.info(f"[STOCK] Write() document final...")
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:
logger.info(f"[STOCK] ✅ Changements sauvegardés")
return {
"numero": numero,
"type": 0,
"date": str(getattr(doc, "DO_Date", "")),
"nb_lignes": len(stocks_mis_a_jour),
"reference": entree_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()
logger.info(f"[STOCK] Rollback effectué")
except:
pass
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_QteMin", 0.0)
)
except:
pass
try:
qte_maxi = float(
getattr(stock, "AS_QteMax", 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_QteMin", 0.0
)
),
"qte_maxi": float(
getattr(
stock_item, "AS_QteMax", 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_QteMin",
0.0,
)
),
"qte_maxi": float(
getattr(
stock,
"AS_QteMax",
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_demandee, depot_code=None):
try:
stock_info = self.lire_stock_article(article_ref)
if depot_code:
# Vérifier dans un dépôt spécifique
depot_trouve = next(
(d for d in stock_info["depots"] if d["code"] == depot_code), None
)
if not depot_trouve:
return {
"suffisant": False,
"stock_disponible": 0.0,
"quantite_demandee": quantite_demandee,
"stock_apres": -quantite_demandee,
"erreur": f"Article non présent dans le dépôt {depot_code}",
}
stock_dispo = depot_trouve["quantite"]
else:
# Vérifier sur le stock total
stock_dispo = stock_info["stock_total"]
suffisant = stock_dispo >= quantite_demandee
stock_apres = stock_dispo - quantite_demandee
return {
"suffisant": suffisant,
"stock_disponible": stock_dispo,
"quantite_demandee": quantite_demandee,
"stock_apres": stock_apres,
"depot": depot_code or "TOUS",
}
except Exception as e:
logger.error(f"Erreur vérification stock : {e}")
raise