Sage100-ws/sage_connector.py

6433 lines
266 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 contextlib import contextmanager
from config import settings, validate_settings
logger = logging.getLogger(__name__)
class SageConnector:
"""
Connecteur Sage 100c avec gestion COM threading correcte
CHANGEMENTS PRODUCTION:
- Initialisation COM par thread (CoInitialize/CoUninitialize)
- Lock robuste pour thread-safety
- Gestion d'erreurs exhaustive
- Logging structuré
- Retry automatique sur erreurs COM
"""
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
# Caches existants
self._cache_clients: List[Dict] = []
self._cache_articles: List[Dict] = []
self._cache_clients_dict: Dict[str, Dict] = {}
self._cache_articles_dict: Dict[str, Dict] = {}
# Métadonnées cache existantes
self._cache_clients_last_update: Optional[datetime] = None
self._cache_articles_last_update: Optional[datetime] = None
self._cache_ttl_minutes = 15
# Thread d'actualisation
self._refresh_thread: Optional[threading.Thread] = None
self._stop_refresh = threading.Event()
# Locks
self._lock_clients = threading.RLock()
self._lock_articles = threading.RLock()
self._lock_com = threading.RLock()
# Thread-local storage pour COM
self._thread_local = threading.local()
# =========================================================================
# GESTION COM THREAD-SAFE
# =========================================================================
@contextmanager
def _com_context(self):
"""
Context manager pour initialiser COM dans chaque thread
CRITIQUE: FastAPI utilise un pool de threads.
Chaque thread doit initialiser COM avant d'utiliser les objets Sage.
"""
# 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
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"""
try:
with self._com_context():
self.cial = win32com.client.gencache.EnsureDispatch(
"Objets100c.Cial.Stream"
)
self.cial.Name = self.chemin_base
self.cial.Loggable.UserName = self.utilisateur
self.cial.Loggable.UserPwd = self.mot_de_passe
self.cial.Open()
logger.info(f"✅ Connexion Sage réussie: {self.chemin_base}")
# Chargement initial du cache
logger.info("📦 Chargement initial du cache...")
self._refresh_cache_clients()
self._refresh_cache_articles()
# Démarrage du thread d'actualisation
self._start_refresh_thread()
return True
except Exception as e:
logger.error(f"❌ Erreur connexion Sage: {e}", exc_info=True)
return False
def deconnecter(self):
"""Déconnexion propre"""
self._stop_refresh.set()
if self._refresh_thread:
self._refresh_thread.join(timeout=5)
if self.cial:
try:
with self._com_context():
self.cial.Close()
logger.info("Connexion Sage fermée")
except:
pass
# =========================================================================
# SYSTÈME DE CACHE
# =========================================================================
def _start_refresh_thread(self):
"""Démarre le thread d'actualisation automatique"""
def refresh_loop():
pythoncom.CoInitialize()
try:
while not self._stop_refresh.is_set():
time.sleep(60)
# Clients
if self._cache_clients_last_update:
age = datetime.now() - self._cache_clients_last_update
if age.total_seconds() > self._cache_ttl_minutes * 60:
self._refresh_cache_clients()
# Articles
if self._cache_articles_last_update:
age = datetime.now() - self._cache_articles_last_update
if age.total_seconds() > self._cache_ttl_minutes * 60:
self._refresh_cache_articles()
finally:
pythoncom.CoUninitialize()
self._refresh_thread = threading.Thread(
target=refresh_loop, daemon=True, name="SageCacheRefresh"
)
self._refresh_thread.start()
def _refresh_cache_clients(self):
"""
Actualise le cache des clients
Charge TOUS les tiers (CT_Type=0 ET CT_Type=1)
"""
if not self.cial:
return
clients = []
clients_dict = {}
try:
with self._com_context(), self._lock_com:
factory = self.cial.CptaApplication.FactoryClient
index = 1
erreurs_consecutives = 0
max_erreurs = 50
logger.info("🔄 Actualisation cache clients et prospects...")
while index < 10000 and erreurs_consecutives < max_erreurs:
try:
persist = factory.List(index)
if persist is None:
break
obj = self._cast_client(persist)
if obj:
data = self._extraire_client(obj)
# ✅ INCLURE TOUS LES TYPES (clients, prospects)
clients.append(data)
clients_dict[data["numero"]] = data
erreurs_consecutives = 0
index += 1
except Exception as e:
erreurs_consecutives += 1
index += 1
if erreurs_consecutives >= max_erreurs:
logger.warning(
f"Arrêt refresh clients après {max_erreurs} erreurs"
)
break
with self._lock_clients:
self._cache_clients = clients
self._cache_clients_dict = clients_dict
self._cache_clients_last_update = datetime.now()
# 📊 Statistiques détaillées
nb_clients = sum(
1
for c in clients
if c.get("type") == 0 and not c.get("est_prospect")
)
nb_prospects = sum(
1 for c in clients if c.get("type") == 0 and c.get("est_prospect")
)
logger.info(
f"✅ Cache actualisé: {len(clients)} tiers "
f"({nb_clients} clients, {nb_prospects} prospects)"
)
except Exception as e:
logger.error(f"❌ Erreur refresh clients: {e}", exc_info=True)
def _refresh_cache_articles(self):
"""Actualise le cache des articles"""
if not self.cial:
return
articles = []
articles_dict = {}
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryArticle
index = 1
erreurs_consecutives = 0
max_erreurs = 50
while index < 10000 and erreurs_consecutives < max_erreurs:
try:
persist = factory.List(index)
if persist is None:
break
obj = self._cast_article(persist)
if obj:
data = self._extraire_article(obj)
articles.append(data)
articles_dict[data["reference"]] = data
erreurs_consecutives = 0
index += 1
except Exception as e:
erreurs_consecutives += 1
index += 1
if erreurs_consecutives >= max_erreurs:
logger.warning(
f"Arrêt refresh articles après {max_erreurs} erreurs"
)
break
with self._lock_articles:
self._cache_articles = articles
self._cache_articles_dict = articles_dict
self._cache_articles_last_update = datetime.now()
logger.info(f" Cache articles actualisé: {len(articles)} articles")
except Exception as e:
logger.error(f" Erreur refresh articles: {e}", exc_info=True)
def lister_tous_fournisseurs(self, filtre=""):
"""
✅ CORRECTION FINALE : Liste fournisseurs SANS passer par _extraire_client()
BYPASS TOTAL de _extraire_client() car :
- Les objets fournisseurs n'ont pas les mêmes champs que les clients
- _extraire_client() plante sur "CT_Qualite" (n'existe pas sur fournisseurs)
- Le diagnostic fournisseurs-analyse-complete fonctionne SANS _extraire_client()
→ On fait EXACTEMENT comme le diagnostic qui marche
"""
if not self.cial:
logger.error("❌ self.cial est None")
return []
fournisseurs = []
try:
with self._com_context(), self._lock_com:
logger.info(
f"🔍 Lecture fournisseurs via FactoryFournisseur (filtre='{filtre}')"
)
factory = self.cial.CptaApplication.FactoryFournisseur
index = 1
max_iterations = 10000
erreurs_consecutives = 0
max_erreurs = 50
filtre_lower = filtre.lower() if filtre else ""
while index < max_iterations and erreurs_consecutives < max_erreurs:
try:
persist = factory.List(index)
if persist is None:
logger.debug(f"Fin de liste à l'index {index}")
break
# Cast
fourn = self._cast_client(persist)
if fourn:
# ✅✅✅ EXTRACTION DIRECTE (pas de _extraire_client) ✅✅✅
try:
numero = getattr(fourn, "CT_Num", "").strip()
intitule = getattr(fourn, "CT_Intitule", "").strip()
if not numero:
logger.debug(f"Index {index}: CT_Num vide, skip")
erreurs_consecutives += 1
index += 1
continue
# Construction objet minimal
data = {
"numero": numero,
"intitule": intitule,
"type": 1, # Fournisseur
"est_fournisseur": True,
}
# Champs optionnels (avec gestion d'erreur)
try:
adresse_obj = getattr(fourn, "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"] = ""
try:
telecom_obj = getattr(fourn, "Telecom", None)
if telecom_obj:
data["telephone"] = getattr(
telecom_obj, "Telephone", ""
).strip()
data["email"] = getattr(
telecom_obj, "EMail", ""
).strip()
except:
data["telephone"] = ""
data["email"] = ""
# Filtrer si nécessaire
if (
not filtre_lower
or filtre_lower in numero.lower()
or filtre_lower in intitule.lower()
):
fournisseurs.append(data)
logger.debug(
f"✅ Fournisseur ajouté: {numero} - {intitule}"
)
erreurs_consecutives = 0
except Exception as e:
logger.debug(f"⚠️ Erreur extraction index {index}: {e}")
erreurs_consecutives += 1
else:
erreurs_consecutives += 1
index += 1
except Exception as e:
logger.debug(f"⚠️ Erreur index {index}: {e}")
erreurs_consecutives += 1
index += 1
if erreurs_consecutives >= max_erreurs:
logger.warning(
f"⚠️ Arrêt après {max_erreurs} erreurs consécutives"
)
break
logger.info(f"{len(fournisseurs)} fournisseurs retournés")
return fournisseurs
except Exception as e:
logger.error(f"❌ Erreur liste fournisseurs: {e}", exc_info=True)
return []
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
"""
✅ Crée un nouveau fournisseur dans Sage 100c via FactoryFournisseur
IMPORTANT: Utilise FactoryFournisseur.Create() et NON FactoryClient.Create()
car les fournisseurs sont gérés séparément dans Sage.
Args:
fournisseur_data: Dictionnaire contenant:
- intitule (obligatoire): Raison sociale
- compte_collectif (défaut: "401000"): Compte général
- num (optionnel): Code fournisseur personnalisé
- adresse, code_postal, ville, pays
- email, telephone
- siret, tva_intra
Returns:
Dict contenant le fournisseur créé avec son numéro définitif
Raises:
ValueError: Si le fournisseur existe déjà ou données invalides
RuntimeError: Si erreur technique Sage
"""
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:
"""
✏️ Modification d'un fournisseur existant dans Sage 100c
IMPORTANT: Utilise FactoryFournisseur.ReadNumero() pour charger le fournisseur
Args:
code: Code du fournisseur à modifier
fournisseur_data: Dictionnaire avec les champs à mettre à jour
Returns:
Fournisseur modifié
"""
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 lire_fournisseur(self, code):
"""
✅ NOUVEAU : Lecture d'un fournisseur par code
Utilise FactoryFournisseur.ReadNumero() directement
"""
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory = self.cial.CptaApplication.FactoryFournisseur
persist = factory.ReadNumero(code)
if not persist:
logger.warning(f"Fournisseur {code} introuvable")
return None
fourn = self._cast_client(persist)
if not fourn:
return None
# Extraction directe (même logique que lister_tous_fournisseurs)
numero = getattr(fourn, "CT_Num", "").strip()
intitule = getattr(fourn, "CT_Intitule", "").strip()
data = {
"numero": numero,
"intitule": intitule,
"type": 1,
"est_fournisseur": True,
}
# Adresse
try:
adresse_obj = getattr(fourn, "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(fourn, "Telecom", None)
if telecom_obj:
data["telephone"] = getattr(
telecom_obj, "Telephone", ""
).strip()
data["email"] = getattr(telecom_obj, "EMail", "").strip()
except:
data["telephone"] = ""
data["email"] = ""
logger.info(f"✅ Fournisseur {code} lu: {intitule}")
return data
except Exception as e:
logger.error(f"❌ Erreur lecture fournisseur {code}: {e}")
return None
# =========================================================================
# API PUBLIQUE (ultra-rapide grâce au cache)
# =========================================================================
def lister_tous_clients(self, filtre=""):
"""Retourne les clients depuis le cache (instantané)"""
with self._lock_clients:
if not filtre:
return self._cache_clients.copy()
filtre_lower = filtre.lower()
return [
c
for c in self._cache_clients
if filtre_lower in c["numero"].lower()
or filtre_lower in c["intitule"].lower()
]
def lire_client(self, code_client):
"""Retourne un client depuis le cache (instantané)"""
with self._lock_clients:
return self._cache_clients_dict.get(code_client)
def lister_tous_articles(self, filtre=""):
"""Retourne les articles depuis le cache (instantané)"""
with self._lock_articles:
if not filtre:
return self._cache_articles.copy()
filtre_lower = filtre.lower()
return [
a
for a in self._cache_articles
if filtre_lower in a["reference"].lower()
or filtre_lower in a["designation"].lower()
]
def lire_article(self, reference):
"""Retourne un article depuis le cache (instantané)"""
with self._lock_articles:
return self._cache_articles_dict.get(reference)
def forcer_actualisation_cache(self):
"""Force l'actualisation immédiate du cache (endpoint admin)"""
logger.info("🔄 Actualisation forcée du cache...")
self._refresh_cache_clients()
self._refresh_cache_articles()
logger.info("✅ Cache actualisé")
logger.info("Cache actualisé")
def get_cache_info(self):
"""Retourne les infos du cache (endpoint monitoring)"""
with self._lock_clients:
info = {
"clients": {
"count": len(self._cache_clients),
"last_update": (
self._cache_clients_last_update.isoformat()
if self._cache_clients_last_update
else None
),
"age_minutes": (
(
datetime.now() - self._cache_clients_last_update
).total_seconds()
/ 60
if self._cache_clients_last_update
else None
),
},
"articles": {
"count": len(self._cache_articles),
"last_update": (
self._cache_articles_last_update.isoformat()
if self._cache_articles_last_update
else None
),
"age_minutes": (
(
datetime.now() - self._cache_articles_last_update
).total_seconds()
/ 60
if self._cache_articles_last_update
else None
),
},
}
info["ttl_minutes"] = self._cache_ttl_minutes
return info
# =========================================================================
# 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
# =========================================================================
# EXTRACTION
# =========================================================================
def _extraire_client(self, client_obj):
"""
✅ CORRECTION : Extraction ULTRA-ROBUSTE pour clients ET fournisseurs
Gère tous les cas où des champs peuvent être manquants
"""
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 MINIMAL ===
data = {
"numero": numero,
"intitule": intitule,
}
# === 3. CHAMPS OPTIONNELS (avec try-except individuels) ===
# Type
try:
data["type"] = getattr(client_obj, "CT_Type", 0)
except:
data["type"] = 0
# Qualité
try:
qualite = getattr(client_obj, "CT_Qualite", None)
data["qualite"] = qualite
data["est_fournisseur"] = (
qualite in [2, 3] if qualite is not None else False
)
except:
data["qualite"] = None
data["est_fournisseur"] = False
# Prospect
try:
data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1
except:
data["est_prospect"] = False
# === 4. ADRESSE (non critique) ===
try:
adresse = getattr(client_obj, "Adresse", None)
if adresse:
try:
data["adresse"] = getattr(adresse, "Adresse", "").strip()
except:
data["adresse"] = ""
try:
data["code_postal"] = getattr(adresse, "CodePostal", "").strip()
except:
data["code_postal"] = ""
try:
data["ville"] = getattr(adresse, "Ville", "").strip()
except:
data["ville"] = ""
except Exception as e:
logger.debug(f"⚠️ Erreur adresse sur {numero}: {e}")
data["adresse"] = ""
data["code_postal"] = ""
data["ville"] = ""
# === 5. TELECOM (non critique) ===
try:
telecom = getattr(client_obj, "Telecom", None)
if telecom:
try:
data["telephone"] = getattr(telecom, "Telephone", "").strip()
except:
data["telephone"] = ""
try:
data["email"] = getattr(telecom, "EMail", "").strip()
except:
data["email"] = ""
except Exception as e:
logger.debug(f"⚠️ Erreur telecom sur {numero}: {e}")
data["telephone"] = ""
data["email"] = ""
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):
return {
"reference": getattr(article_obj, "AR_Ref", ""),
"designation": getattr(article_obj, "AR_Design", ""),
"prix_vente": getattr(article_obj, "AR_PrixVen", 0.0),
"prix_achat": getattr(article_obj, "AR_PrixAch", 0.0),
"stock_reel": getattr(article_obj, "AR_Stock", 0.0),
"stock_mini": getattr(article_obj, "AR_StockMini", 0.0),
}
# =========================================================================
# CRÉATION DEVIS (US-A1) - VERSION TRANSACTIONNELLE
# =========================================================================
def creer_devis_enrichi(self, devis_data: dict, forcer_brouillon: bool = False):
"""
Création de devis OPTIMISÉE - Version hybride
Args:
devis_data: Données du devis
forcer_brouillon: Si True, crée en statut 0 (Brouillon)
Si False, laisse Sage décider (généralement statut 2)
✅ AVANTAGES:
- Rapide comme l'ancienne version
- Possibilité de forcer en brouillon si nécessaire
- Pas d'attentes inutiles
- Relecture simplifiée
"""
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)}")
# =========================================================================
# LECTURE DEVIS
# =========================================================================
def lire_devis(self, numero_devis):
"""
Lecture d'un devis (y compris brouillon)
✅ ENRICHI: Inclut maintenant a_deja_ete_transforme
❌ N'utilise JAMAIS List() - uniquement ReadPiece
"""
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
# ✅ UNIQUEMENT ReadPiece
try:
persist = factory.ReadPiece(0, numero_devis)
if persist:
logger.info(f"✅ Devis {numero_devis} trouvé via ReadPiece")
else:
logger.warning(
f"❌ Devis {numero_devis} introuvable via ReadPiece"
)
return None
except Exception as e:
logger.error(f"❌ ReadPiece échoué pour {numero_devis}: {e}")
return None
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
# ✅ Charger client
client_code = ""
client_intitule = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
client_intitule = getattr(client_obj, "CT_Intitule", "").strip()
except Exception as e:
logger.debug(f"Erreur chargement client: {e}")
devis = {
"numero": getattr(doc, "DO_Piece", ""),
"date": str(getattr(doc, "DO_Date", "")),
"client_code": client_code,
"client_intitule": client_intitule,
"total_ht": float(getattr(doc, "DO_TotalHT", 0.0)),
"total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)),
"statut": getattr(doc, "DO_Statut", 0),
"lignes": [],
}
# ✅✅ NOUVEAU: Vérifier si déjà transformé
try:
verif = self.verifier_si_deja_transforme(numero_devis, 0)
devis["a_deja_ete_transforme"] = verif.get("deja_transforme", False)
devis["documents_cibles"] = verif.get("documents_cibles", [])
logger.info(
f"📊 Devis {numero_devis}: "
f"transformé={devis['a_deja_ete_transforme']}, "
f"nb_docs_cibles={len(devis['documents_cibles'])}"
)
except Exception as e:
logger.warning(f"⚠️ Erreur vérification transformation: {e}")
devis["a_deja_ete_transforme"] = False
devis["documents_cibles"] = []
# Lecture des lignes
try:
factory_lignes = doc.FactoryDocumentLigne
except:
factory_lignes = doc.FactoryDocumentVenteLigne
index = 1
while True:
try:
ligne_persist = factory_lignes.List(index)
if ligne_persist is None:
break
ligne = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
ligne.Read()
# Charger 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 Exception as e:
logger.debug(
f"Erreur chargement article ligne {index}: {e}"
)
devis["lignes"].append(
{
"article": 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)
),
}
)
index += 1
except Exception as e:
logger.debug(f"Erreur lecture ligne {index}: {e}")
break
logger.info(
f"✅ Devis {numero_devis} lu: {len(devis['lignes'])} lignes, "
f"{devis['total_ttc']:.2f}€, statut={devis['statut']}, "
f"transformé={devis['a_deja_ete_transforme']}"
)
return devis
except Exception as e:
logger.error(f"❌ Erreur lecture devis {numero_devis}: {e}", exc_info=True)
return None
def lire_document(self, numero, type_doc):
"""
Lecture générique document
✅ AJOUT: Retourne maintenant DO_Ref
"""
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
persist = factory.ReadPiece(type_doc, numero)
if not persist:
return None
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
# Charger client via .Client
client_code = ""
client_intitule = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
client_intitule = getattr(client_obj, "CT_Intitule", "").strip()
except Exception as e:
logger.debug(f"Erreur chargement client: {e}")
# Lire lignes
lignes = []
try:
factory_lignes = doc.FactoryDocumentLigne
except:
factory_lignes = doc.FactoryDocumentVenteLigne
index = 1
while True:
try:
ligne_p = factory_lignes.List(index)
if ligne_p is None:
break
ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3")
ligne.Read()
# Charger article via .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
lignes.append(
{
"article": article_ref,
"designation": getattr(ligne, "DL_Design", ""),
"quantite": getattr(ligne, "DL_Qte", 0.0),
"prix_unitaire": getattr(ligne, "DL_PrixUnitaire", 0.0),
"montant_ht": getattr(ligne, "DL_MontantHT", 0.0),
}
)
index += 1
except:
break
return {
"numero": getattr(doc, "DO_Piece", ""),
"reference": getattr(doc, "DO_Ref", ""), # ✅ AJOUT
"date": str(getattr(doc, "DO_Date", "")),
"client_code": client_code,
"client_intitule": client_intitule,
"total_ht": getattr(doc, "DO_TotalHT", 0.0),
"total_ttc": getattr(doc, "DO_TotalTTC", 0.0),
"statut": getattr(doc, "DO_Statut", 0),
"lignes": lignes,
}
except Exception as e:
logger.error(f"❌ Erreur lecture document: {e}")
return None
def verifier_si_deja_transforme(self, numero_source: str, type_source: int) -> Dict:
"""
🔍 Vérifie si un document a déjà été transformé
✅ ULTRA-OPTIMISÉ: Utilise ReadPiece avec DO_Ref au lieu de scanner List()
Performance:
- Ancienne méthode: 30+ secondes (scan de 10000+ documents)
- Nouvelle méthode: < 1 seconde (lectures directes ciblées)
Stratégie:
1. Construire les numéros potentiels basés sur les conventions Sage
2. Tester directement avec ReadPiece
3. Limite stricte de 50 documents à scanner en dernier recours
Returns:
{
"deja_transforme": bool,
"documents_cibles": [
{"numero": "BC00001", "type": 10, "date": "..."}
]
}
"""
if not self.cial:
return {"deja_transforme": False, "documents_cibles": []}
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
documents_cibles = []
logger.info(f"🔍 Vérification transformations pour {numero_source}...")
# ========================================
# MÉTHODE 1: DEVINER LES NUMÉROS CIBLES PAR CONVENTION
# ========================================
# Extraire le numéro de base (ex: "00001" depuis "DE00001")
import re
match = re.search(r"(\d+)$", numero_source)
if match:
numero_base = match.group(1)
# Mapper les préfixes selon les types
prefixes_par_type = {
10: ["BC", "CMD"], # Bon de commande
30: ["BL", "LIV"], # Bon de livraison
60: ["FA", "FACT"], # Facture
}
# Types cibles possibles selon le type source
types_cibles_possibles = {
0: [10, 60], # Devis → Commande ou Facture
10: [30, 60], # Commande → BL ou Facture
30: [60], # BL → Facture
}
types_a_tester = types_cibles_possibles.get(type_source, [])
# Tester chaque combinaison type/préfixe
for type_cible in types_a_tester:
for prefix in prefixes_par_type.get(type_cible, []):
numero_potentiel = f"{prefix}{numero_base}"
try:
persist = factory.ReadPiece(
type_cible, numero_potentiel
)
if persist:
doc = win32com.client.CastTo(
persist, "IBODocumentVente3"
)
doc.Read()
# Vérifier que DO_Ref correspond bien
ref_origine = getattr(doc, "DO_Ref", "").strip()
if (
numero_source in ref_origine
or ref_origine == numero_source
):
documents_cibles.append(
{
"numero": getattr(doc, "DO_Piece", ""),
"type": type_cible,
"type_libelle": self._get_type_libelle(
type_cible
),
"date": str(
getattr(doc, "DO_Date", "")
),
"reference": ref_origine,
"total_ttc": float(
getattr(doc, "DO_TotalTTC", 0.0)
),
"statut": getattr(doc, "DO_Statut", -1),
"methode_detection": "convention_nommage",
}
)
logger.info(
f"✅ Trouvé via convention: {numero_potentiel} "
f"(DO_Ref={ref_origine})"
)
except:
# Ce numéro n'existe pas, continuer
continue
# ========================================
# MÉTHODE 2: SCAN ULTRA-LIMITÉ (max 50 documents)
# ========================================
# Seulement si rien trouvé ET que c'est critique
if not documents_cibles:
logger.info(f"🔍 Scan limité (max 50 documents)...")
index = 1
max_scan = 100 # ⚡ LIMITE STRICTE à 50 au lieu de 500
while index < max_scan:
try:
persist = factory.List(index)
if persist is None:
break
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
# Vérifier DO_Ref
ref_origine = getattr(doc, "DO_Ref", "").strip()
if (
numero_source in ref_origine
or ref_origine == numero_source
):
doc_type = getattr(doc, "DO_Type", -1)
documents_cibles.append(
{
"numero": getattr(doc, "DO_Piece", ""),
"type": doc_type,
"type_libelle": self._get_type_libelle(
doc_type
),
"date": str(getattr(doc, "DO_Date", "")),
"reference": ref_origine,
"total_ttc": float(
getattr(doc, "DO_TotalTTC", 0.0)
),
"statut": getattr(doc, "DO_Statut", -1),
"methode_detection": "scan_limite",
}
)
logger.info(
f"✅ Trouvé via scan: {getattr(doc, 'DO_Piece', '')} "
f"à l'index {index}"
)
index += 1
except Exception as e:
index += 1
continue
# ========================================
# RÉSULTAT
# ========================================
logger.info(
f"📊 Résultat vérification {numero_source}: "
f"{len(documents_cibles)} transformation(s) trouvée(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 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):
"""
🔧 Transformation de document - VERSION FUSIONNÉE FINALE
✅ Copie DO_Ref du source vers la cible (du nouveau)
✅ Ne modifie JAMAIS le statut du document source
✅ Préserve toutes les lignes correctement (de l'ancien)
"""
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
type_source = int(type_source)
type_cible = int(type_cible)
logger.info(
f"[TRANSFORM] Demande : {numero_source} "
f"(type {type_source}) -> type {type_cible}"
)
# Matrice de transformations
transformations_autorisees = {
(0, 10): "Devis -> Commande",
(10, 30): "Commande -> Bon de livraison",
(10, 60): "Commande -> Facture",
(30, 60): "Bon de livraison -> Facture",
(0, 60): "Devis -> Facture",
}
if (type_source, type_cible) not in transformations_autorisees:
raise ValueError(
f"Transformation non autorisée: {type_source} -> {type_cible}"
)
# ========================================
# VÉRIFICATION AUTOMATIQUE DES 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)
else:
logger.warning(
f"[TRANSFORM] ⚠️ Le document {numero_source} a déjà été transformé "
f"{len(docs_existants)} fois vers d'autres types"
)
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)
type_reel = getattr(doc_source, "DO_Type", -1)
logger.info(
f"[TRANSFORM] Source: type={type_reel}, statut={statut_actuel}"
)
# ========================================
# ÉTAPE 2 : EXTRAIRE LES DONNÉES SOURCE
# ========================================
logger.info("[TRANSFORM] Extraction données source...")
# Client
client_code = ""
client_obj = None
try:
client_obj = getattr(doc_source, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
except Exception as e:
logger.error(f"Erreur lecture client: {e}")
raise ValueError(f"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
date_source = getattr(doc_source, "DO_Date", None)
# ✅ NOUVEAU: Référence externe (DO_Ref) - UTILISER LE NUMÉRO SOURCE
reference_pour_cible = numero_source
logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}")
# Lignes
lignes_source = []
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écupérer 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
lignes_source.append(
{
"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)
),
"remise": float(
getattr(ligne, "DL_Remise01REM_Valeur", 0.0)
),
"type_remise": int(
getattr(ligne, "DL_Remise01REM_Type", 0)
),
}
)
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)")
# ========================================
# ÉTAPE 3 : 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 4 : CRÉER LE 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 5 : 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 6 : 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.SetClient(client_obj_cible)
logger.info(
f"[TRANSFORM] SetClient() appelé pour {client_code}"
)
except Exception as e:
logger.warning(
f"[TRANSFORM] SetClient() échoue: {e}, tentative SetDefaultClient()"
)
doc_cible.SetDefaultClient(client_obj_cible)
# ✅ FUSION: Définir DO_Ref AVANT le premier 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}")
doc_cible.Write()
# Vérifier que le client est bien attaché
doc_cible.Read()
client_verifie = getattr(doc_cible, "CT_Num", None)
if not client_verifie:
try:
client_test = getattr(doc_cible, "Client", None)
if client_test:
client_test.Read()
client_verifie = getattr(client_test, "CT_Num", None)
except:
pass
if not client_verifie:
raise ValueError(f"Echec association client {client_code}")
logger.info(
f"[TRANSFORM] Client {client_code} associé (CT_Num={client_verifie})"
)
client_obj_sauvegarde = client_obj_cible
# ========================================
# ÉTAPE 7 : COPIER LES LIGNES
# ========================================
logger.info(f"[TRANSFORM] Copie de {nb_lignes} lignes...")
try:
factory_lignes_cible = doc_cible.FactoryDocumentLigne
except:
factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne
factory_article = self.cial.FactoryArticle
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"]
try:
ligne_obj.SetDefaultArticleReference(article_ref, quantite)
except:
try:
ligne_obj.SetDefaultArticle(article_obj, quantite)
except:
ligne_obj.DL_Design = ligne_data["designation"]
ligne_obj.DL_Qte = quantite
prix = ligne_data["prix_unitaire"]
if prix > 0:
ligne_obj.DL_PrixUnitaire = float(prix)
remise = ligne_data["remise"]
if remise > 0:
try:
ligne_obj.DL_Remise01REM_Valeur = float(remise)
ligne_obj.DL_Remise01REM_Type = ligne_data[
"type_remise"
]
except:
pass
ligne_obj.Write()
# ✅ FUSION: Log détaillé de la ligne écrite
logger.debug(
f"[TRANSFORM] Ligne {idx} écrite: {article_ref} x{quantite} @ {prix}"
)
logger.info(f"[TRANSFORM] {nb_lignes} lignes copiées")
# ========================================
# ÉTAPE 8 : COMPLÉTER LES CHAMPS OBLIGATOIRES
# ========================================
if type_cible == 60: # Facture
logger.info(
"[TRANSFORM] Complétion champs obligatoires facture..."
)
try:
journal = (
getattr(doc_source, "DO_CodeJournal", None) or "VTE"
)
if hasattr(doc_cible, "DO_CodeJournal"):
doc_cible.DO_CodeJournal = journal
except Exception as e:
logger.warning(f"Code journal: {e}")
try:
souche = getattr(doc_source, "DO_Souche", 0)
if hasattr(doc_cible, "DO_Souche"):
doc_cible.DO_Souche = souche
except:
pass
try:
regime = getattr(doc_source, "DO_Regime", None)
if regime is not None and hasattr(doc_cible, "DO_Regime"):
doc_cible.DO_Regime = regime
except:
pass
# ========================================
# ÉTAPE 9 : RÉASSOCIER LE CLIENT
# ========================================
logger.info("[TRANSFORM] Réassociation client avant validation...")
try:
doc_cible.SetClient(client_obj_sauvegarde)
except:
doc_cible.SetDefaultClient(client_obj_sauvegarde)
logger.info("[TRANSFORM] Écriture document finale...")
doc_cible.Write()
# ========================================
# ÉTAPE 10 : VALIDER LE DOCUMENT
# ========================================
logger.info("[TRANSFORM] Validation document cible...")
doc_cible.Read()
client_final = getattr(doc_cible, "CT_Num", None)
if not client_final:
try:
client_obj_test = getattr(doc_cible, "Client", None)
if client_obj_test:
client_obj_test.Read()
client_final = getattr(client_obj_test, "CT_Num", None)
except:
pass
if not client_final:
logger.warning(
"Client perdu ! Tentative réassociation urgence..."
)
try:
doc_cible.SetClient(client_obj_sauvegarde)
except:
doc_cible.SetDefaultClient(client_obj_sauvegarde)
doc_cible.Write()
doc_cible.Read()
client_final = getattr(doc_cible, "CT_Num", None)
if not client_final:
raise ValueError(f"Client {client_code} impossible à associer")
logger.info(f"[TRANSFORM] ✅ Client confirmé: {client_final}")
try:
logger.info("[TRANSFORM] Appel Process()...")
process.Process()
logger.info("[TRANSFORM] Document cible validé avec succès")
except Exception as e:
logger.error(f"[TRANSFORM] ERREUR Process(): {e}")
raise
# ========================================
# ÉTAPE 11 : RÉCUPÉRER LE NUMÉRO
# ========================================
numero_cible = None
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", "")
except:
pass
if not numero_cible:
numero_cible = getattr(doc_cible, "DO_Piece", "")
if not numero_cible:
raise RuntimeError("Numéro document cible vide")
logger.info(f"[TRANSFORM] Document cible créé: {numero_cible}")
# ========================================
# ÉTAPE 12 : COMMIT (STATUT SOURCE INCHANGÉ)
# ========================================
if transaction_active:
try:
self.cial.CptaApplication.CommitTrans()
logger.info("[TRANSFORM] Transaction committée")
except:
pass
# Attente indexation
time.sleep(1)
# ✅ LE DOCUMENT SOURCE GARDE SON STATUT ACTUEL
# ✅ FUSION: Message final clair
logger.info(
f"[TRANSFORM] ✅ SUCCÈS: {numero_source} ({type_source}) -> "
f"{numero_cible} ({type_cible}) - {nb_lignes} lignes - "
f"Référence: {reference_pour_cible} - Statut source inchangé"
)
return {
"success": True,
"document_source": numero_source,
"document_cible": numero_cible,
"nb_lignes": nb_lignes,
}
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
# =========================================================================
# CHAMPS LIBRES (US-A3)
# =========================================================================
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
# =========================================================================
# US-A6 - LECTURE CONTACTS
# =========================================================================
def lire_contact_principal_client(self, code_client):
"""
NOUVEAU: Lecture contact principal d'un client
Pour US-A6: relance devis via Universign
Récupère l'email du contact principal pour l'envoi
"""
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
# =========================================================================
# US-A7 - MAJ CHAMP DERNIERE RELANCE
# =========================================================================
def mettre_a_jour_derniere_relance(self, doc_id, type_doc):
"""
NOUVEAU: Met à jour le champ libre "Dernière relance"
Pour US-A7: relance facture en un clic
"""
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
)
# =========================================================================
# PROSPECTS (CT_Type = 0 AND CT_Prospect = 1)
# =========================================================================
def lister_tous_prospects(self, filtre=""):
"""Liste tous les prospects depuis le cache"""
with self._lock_clients:
if not filtre:
return [
c
for c in self._cache_clients
if c.get("type") == 0 and c.get("est_prospect")
]
filtre_lower = filtre.lower()
return [
c
for c in self._cache_clients
if c.get("type") == 0
and c.get("est_prospect")
and (
filtre_lower in c["numero"].lower()
or filtre_lower in c["intitule"].lower()
)
]
def lire_prospect(self, code_prospect):
"""Retourne un prospect depuis le cache"""
with self._lock_clients:
prospect = self._cache_clients_dict.get(code_prospect)
if prospect and prospect.get("type") == 0 and prospect.get("est_prospect"):
return prospect
return None
# =========================================================================
# EXTRACTION CLIENTS (Mise à jour pour inclure prospects)
# =========================================================================
def _extraire_client(self, client_obj):
"""MISE À JOUR : Extraction avec détection prospect"""
data = {
"numero": getattr(client_obj, "CT_Num", ""),
"intitule": getattr(client_obj, "CT_Intitule", ""),
"type": getattr(
client_obj, "CT_Type", 0
), # 0=Client/Prospect, 1=Fournisseur
"est_prospect": getattr(client_obj, "CT_Prospect", 0) == 1, # ✅ NOUVEAU
}
try:
adresse = getattr(client_obj, "Adresse", None)
if adresse:
data["adresse"] = getattr(adresse, "Adresse", "")
data["code_postal"] = getattr(adresse, "CodePostal", "")
data["ville"] = getattr(adresse, "Ville", "")
except:
pass
try:
telecom = getattr(client_obj, "Telecom", None)
if telecom:
data["telephone"] = getattr(telecom, "Telephone", "")
data["email"] = getattr(telecom, "EMail", "")
except:
pass
return data
# =========================================================================
# AVOIRS (DO_Domaine = 0 AND DO_Type = 5)
# =========================================================================
def lister_avoirs(self, limit=100, statut=None):
"""Liste tous les avoirs de vente"""
if not self.cial:
return []
avoirs = []
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
index = 1
max_iterations = limit * 3
erreurs_consecutives = 0
max_erreurs = 50
while (
len(avoirs) < limit
and index < max_iterations
and erreurs_consecutives < max_erreurs
):
try:
persist = factory.List(index)
if persist is None:
break
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
# ✅ Filtrer : DO_Domaine = 0 (Vente) AND DO_Type = 5 (Avoir)
doc_type = getattr(doc, "DO_Type", -1)
doc_domaine = getattr(doc, "DO_Domaine", -1)
if doc_domaine != 0 or doc_type != settings.SAGE_TYPE_BON_AVOIR:
index += 1
continue
doc_statut = getattr(doc, "DO_Statut", 0)
# Filtre statut optionnel
if statut is not None and doc_statut != statut:
index += 1
continue
# Charger client
client_code = ""
client_intitule = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
client_intitule = getattr(
client_obj, "CT_Intitule", ""
).strip()
except:
pass
avoirs.append(
{
"numero": getattr(doc, "DO_Piece", ""),
"reference": getattr(doc, "DO_Ref", ""),
"date": str(getattr(doc, "DO_Date", "")),
"client_code": client_code,
"client_intitule": client_intitule,
"total_ht": float(getattr(doc, "DO_TotalHT", 0.0)),
"total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)),
"statut": doc_statut,
}
)
erreurs_consecutives = 0
index += 1
except:
erreurs_consecutives += 1
index += 1
if erreurs_consecutives >= max_erreurs:
break
logger.info(f"{len(avoirs)} avoirs retournés")
return avoirs
except Exception as e:
logger.error(f"❌ Erreur liste avoirs: {e}", exc_info=True)
return []
def lire_avoir(self, numero):
"""Lecture d'un avoir avec ses lignes"""
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
# Essayer ReadPiece
persist = factory.ReadPiece(settings.SAGE_TYPE_BON_AVOIR, numero)
if not persist:
# Chercher dans List()
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)
== settings.SAGE_TYPE_BON_AVOIR
and getattr(doc_test, "DO_Piece", "") == numero
):
persist = persist_test
break
index += 1
except:
index += 1
if not persist:
return None
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
# Charger client
client_code = ""
client_intitule = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
client_intitule = getattr(client_obj, "CT_Intitule", "").strip()
except:
pass
avoir = {
"numero": getattr(doc, "DO_Piece", ""),
"reference": getattr(doc, "DO_Ref", ""),
"date": str(getattr(doc, "DO_Date", "")),
"client_code": client_code,
"client_intitule": client_intitule,
"total_ht": float(getattr(doc, "DO_TotalHT", 0.0)),
"total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)),
"statut": getattr(doc, "DO_Statut", 0),
"lignes": [],
}
# Charger lignes
try:
factory_lignes = getattr(
doc, "FactoryDocumentLigne", None
) or getattr(doc, "FactoryDocumentVenteLigne", None)
if factory_lignes:
index = 1
while index <= 100:
try:
ligne_persist = factory_lignes.List(index)
if ligne_persist is None:
break
ligne = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
ligne.Read()
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
avoir["lignes"].append(
{
"article": 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)
),
}
)
index += 1
except:
break
except:
pass
logger.info(f"✅ Avoir {numero} lu: {len(avoir['lignes'])} lignes")
return avoir
except Exception as e:
logger.error(f"❌ Erreur lecture avoir {numero}: {e}")
return None
# =========================================================================
# LIVRAISONS (DO_Domaine = 0 AND DO_Type = 3)
# =========================================================================
def lister_livraisons(self, limit=100, statut=None):
"""Liste tous les bons de livraison"""
if not self.cial:
return []
livraisons = []
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
index = 1
max_iterations = limit * 3
erreurs_consecutives = 0
max_erreurs = 50
while (
len(livraisons) < limit
and index < max_iterations
and erreurs_consecutives < max_erreurs
):
try:
persist = factory.List(index)
if persist is None:
break
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
# ✅ Filtrer : DO_Domaine = 0 (Vente) AND DO_Type = 30 (Livraison)
doc_type = getattr(doc, "DO_Type", -1)
doc_domaine = getattr(doc, "DO_Domaine", -1)
if (
doc_domaine != 0
or doc_type != settings.SAGE_TYPE_BON_LIVRAISON
):
index += 1
continue
doc_statut = getattr(doc, "DO_Statut", 0)
if statut is not None and doc_statut != statut:
index += 1
continue
# Charger client
client_code = ""
client_intitule = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
client_intitule = getattr(
client_obj, "CT_Intitule", ""
).strip()
except:
pass
livraisons.append(
{
"numero": getattr(doc, "DO_Piece", ""),
"reference": getattr(doc, "DO_Ref", ""),
"date": str(getattr(doc, "DO_Date", "")),
"client_code": client_code,
"client_intitule": client_intitule,
"total_ht": float(getattr(doc, "DO_TotalHT", 0.0)),
"total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)),
"statut": doc_statut,
}
)
erreurs_consecutives = 0
index += 1
except:
erreurs_consecutives += 1
index += 1
if erreurs_consecutives >= max_erreurs:
break
logger.info(f"{len(livraisons)} livraisons retournées")
return livraisons
except Exception as e:
logger.error(f"❌ Erreur liste livraisons: {e}", exc_info=True)
return []
def lire_livraison(self, numero):
"""Lecture d'une livraison avec ses lignes"""
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
# Essayer ReadPiece
persist = factory.ReadPiece(settings.SAGE_TYPE_BON_LIVRAISON, numero)
if not persist:
# Chercher dans List()
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)
== settings.SAGE_TYPE_BON_LIVRAISON
and getattr(doc_test, "DO_Piece", "") == numero
):
persist = persist_test
break
index += 1
except:
index += 1
if not persist:
return None
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
# Charger client
client_code = ""
client_intitule = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "").strip()
client_intitule = getattr(client_obj, "CT_Intitule", "").strip()
except:
pass
livraison = {
"numero": getattr(doc, "DO_Piece", ""),
"reference": getattr(doc, "DO_Ref", ""),
"date": str(getattr(doc, "DO_Date", "")),
"client_code": client_code,
"client_intitule": client_intitule,
"total_ht": float(getattr(doc, "DO_TotalHT", 0.0)),
"total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)),
"statut": getattr(doc, "DO_Statut", 0),
"lignes": [],
}
# Charger lignes
try:
factory_lignes = getattr(
doc, "FactoryDocumentLigne", None
) or getattr(doc, "FactoryDocumentVenteLigne", None)
if factory_lignes:
index = 1
while index <= 100:
try:
ligne_persist = factory_lignes.List(index)
if ligne_persist is None:
break
ligne = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
ligne.Read()
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
livraison["lignes"].append(
{
"article": 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)
),
}
)
index += 1
except:
break
except:
pass
logger.info(
f"✅ Livraison {numero} lue: {len(livraison['lignes'])} lignes"
)
return livraison
except Exception as e:
logger.error(f"❌ Erreur lecture livraison {numero}: {e}")
return None
# =========================================================================
# CREATION CLIENT (US-A8 ?)
# =========================================================================
def creer_client(self, client_data: Dict) -> Dict:
"""
Crée un nouveau client dans Sage 100c via l'API COM.
✅ VERSION CORRIGÉE : CT_Type supprimé (n'existe pas dans cette version)
"""
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
# ========================================
self._refresh_cache_clients()
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:
"""
✏️ Modification d'un client existant dans Sage 100c
Args:
code: Code du client à modifier
client_data: Dictionnaire avec les champs à mettre à jour
Returns:
Client modifié
"""
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
self._refresh_cache_clients()
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:
"""
✏️ Modification d'un devis - VERSION FINALE OPTIMISÉE
✅ Même stratégie intelligente que modifier_commande
"""
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:
"""
Création d'une commande (type 10 = Bon de commande)
✅ CORRECTION: Gestion identique aux devis
- Prix automatique depuis Sage si non fourni
- Prix = 0 toléré (articles de service, etc.)
- Remise optionnelle
"""
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)}")
# ============================================================================
# CORRECTIF CRITIQUE : Modification devis/commandes
# ============================================================================
def modifier_commande(self, numero: str, commande_data: Dict) -> Dict:
"""
✏️ Modification commande - VERSION SIMPLIFIÉE
🔧 STRATÉGIE REMPLACEMENT LIGNES:
- Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles
- Utilise .Remove() pour la suppression
- Simple, robuste, prévisible
"""
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:
"""
Création d'une livraison (type 30 = Bon de livraison)
✅ Gestion identique aux commandes/devis
"""
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:
"""
✏️ Modification d'une livraison existante
🔧 STRATÉGIE REMPLACEMENT LIGNES:
- Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles
- Utilise .Remove() pour la suppression
"""
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:
"""
Création d'un avoir (type 50 = Bon d'avoir)
✅ Gestion identique aux commandes/devis/livraisons
"""
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:
"""
✏️ Modification d'un avoir existant
🔧 STRATÉGIE REMPLACEMENT LIGNES:
- Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles
- Utilise .Remove() pour la suppression
"""
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:
"""
Création d'une facture (type 60 = Facture)
⚠️ ATTENTION: Les factures ont souvent des champs obligatoires supplémentaires
selon la configuration Sage (DO_CodeJournal, DO_Souche, DO_Regime, etc.)
✅ Gestion identique aux autres documents + champs spécifiques factures
"""
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:
"""
✏️ Modification d'une facture existante
⚠️ ATTENTION: Les factures comptabilisées peuvent être verrouillées par Sage
🔧 STRATÉGIE REMPLACEMENT LIGNES:
- Si nouvelles lignes fournies → Supprime TOUTES les anciennes puis ajoute les nouvelles
- Utilise .Remove() pour la suppression
"""
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)}")