Sage100-ws/sage_connector.py
2025-12-24 21:00:49 +01:00

12180 lines
526 KiB
Python

import win32com.client
import pythoncom # AJOUT CRITIQUE
from datetime import datetime, timedelta, date
from typing import Dict, List, Optional, Any
import threading
import time
import logging
from config import settings, validate_settings
import pyodbc
from contextlib import contextmanager
import pywintypes
import os
import glob
import tempfile
import logging
from dataclasses import dataclass, field
import zlib
import struct
logger = logging.getLogger(__name__)
class SageConnector:
def __init__(self, chemin_base, utilisateur="<Administrateur>", mot_de_passe=""):
self.chemin_base = chemin_base
self.utilisateur = utilisateur
self.mot_de_passe = mot_de_passe
self.cial = None
self.sql_server = "OV-FDDDC6\\SAGE100"
self.sql_database = "BIJOU"
self.sql_conn_string = (
f"DRIVER={{ODBC Driver 17 for SQL Server}};"
f"SERVER={self.sql_server};"
f"DATABASE={self.sql_database};"
f"Trusted_Connection=yes;"
f"Encrypt=no;"
)
self._lock_com = threading.RLock()
# Thread-local storage pour COM
self._thread_local = threading.local()
# =========================================================================
# GESTION COM THREAD-SAFE
# =========================================================================
@contextmanager
def _com_context(self):
# Vérifier si COM est déjà initialisé pour ce thread
if not hasattr(self._thread_local, "com_initialized"):
try:
pythoncom.CoInitialize()
self._thread_local.com_initialized = True
logger.debug(
f"COM initialisé pour thread {threading.current_thread().name}"
)
except Exception as e:
logger.error(f"Erreur initialisation COM: {e}")
raise
try:
yield
finally:
# Ne pas désinitialiser COM ici car le thread peut être réutilisé
pass
@contextmanager
def _get_sql_connection(self):
"""Context manager pour connexions SQL"""
conn = None
try:
conn = pyodbc.connect(self.sql_conn_string, timeout=10)
yield conn
except pyodbc.Error as e:
logger.error(f" Erreur SQL: {e}")
raise RuntimeError(f"Erreur SQL: {str(e)}")
finally:
if conn:
conn.close()
def _safe_strip(self, value):
"""Strip sécurisé pour valeurs SQL"""
if value is None:
return None
if isinstance(value, str):
return value.strip()
return value
def _cleanup_com_thread(self):
"""Nettoie COM pour le thread actuel (à appeler à la fin)"""
if hasattr(self._thread_local, "com_initialized"):
try:
pythoncom.CoUninitialize()
delattr(self._thread_local, "com_initialized")
logger.debug(
f"COM nettoyé pour thread {threading.current_thread().name}"
)
except:
pass
# =========================================================================
# CONNEXION
# =========================================================================
def connecter(self):
"""Connexion initiale à Sage - VERSION HYBRIDE"""
try:
# ========================================
# CONNEXION COM (pour écritures)
# ========================================
with self._com_context():
self.cial = win32com.client.gencache.EnsureDispatch(
"Objets100c.Cial.Stream"
)
self.cial.Name = self.chemin_base
self.cial.Loggable.UserName = self.utilisateur
self.cial.Loggable.UserPwd = self.mot_de_passe
self.cial.Open()
logger.info(f" Connexion COM Sage réussie: {self.chemin_base}")
# ========================================
# TEST CONNEXION SQL (pour lectures)
# ========================================
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM F_COMPTET")
nb_tiers = cursor.fetchone()[0]
logger.info(f" Connexion SQL réussie: {nb_tiers} tiers détectés")
except Exception as e:
logger.warning(f"SQL non disponible: {e}")
logger.warning(" Les lectures utiliseront COM (plus lent)")
return True
except Exception as e:
logger.error(f" Erreur connexion Sage: {e}", exc_info=True)
return False
def deconnecter(self):
"""Déconnexion propre"""
if self.cial:
try:
with self._com_context():
self.cial.Close()
logger.info("Connexion Sage fermée")
except:
pass
def lister_tous_fournisseurs(self, filtre=""):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = """
SELECT
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays,
CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant,
CT_Sommeil, CT_Contact
FROM F_COMPTET
WHERE CT_Type = 1
"""
params = []
if filtre:
query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)"
params.extend([f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY CT_Intitule"
cursor.execute(query, params)
rows = cursor.fetchall()
fournisseurs = []
for row in rows:
fournisseurs.append(
{
"numero": self._safe_strip(row.CT_Num),
"intitule": self._safe_strip(row.CT_Intitule),
"type": 1, # Fournisseur
"est_fournisseur": True,
"qualite": self._safe_strip(row.CT_Qualite),
"adresse": self._safe_strip(row.CT_Adresse),
"ville": self._safe_strip(row.CT_Ville),
"code_postal": self._safe_strip(row.CT_CodePostal),
"pays": self._safe_strip(row.CT_Pays),
"telephone": self._safe_strip(row.CT_Telephone),
"email": self._safe_strip(row.CT_EMail),
"siret": self._safe_strip(row.CT_Siret),
"tva_intra": self._safe_strip(row.CT_Identifiant),
"est_actif": (row.CT_Sommeil == 0),
"contact": self._safe_strip(row.CT_Contact),
}
)
logger.info(f" SQL: {len(fournisseurs)} fournisseurs")
return fournisseurs
except Exception as e:
logger.error(f" Erreur SQL fournisseurs: {e}")
return []
def lire_fournisseur(self, code):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays,
CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie,
CT_Siret, CT_Identifiant, CT_Sommeil,
CT_Contact, CT_FormeJuridique
FROM F_COMPTET
WHERE CT_Num = ? AND CT_Type = 1
""",
(code.upper(),),
)
row = cursor.fetchone()
if not row:
return None
return {
"numero": self._safe_strip(row.CT_Num),
"intitule": self._safe_strip(row.CT_Intitule),
"type": 1,
"est_fournisseur": True,
"qualite": self._safe_strip(row.CT_Qualite),
"adresse": self._safe_strip(row.CT_Adresse),
"complement": self._safe_strip(row.CT_Complement),
"ville": self._safe_strip(row.CT_Ville),
"code_postal": self._safe_strip(row.CT_CodePostal),
"pays": self._safe_strip(row.CT_Pays),
"telephone": self._safe_strip(row.CT_Telephone),
"portable": self._safe_strip(row.CT_Portable),
"email": self._safe_strip(row.CT_EMail),
"telecopie": self._safe_strip(row.CT_Telecopie),
"siret": self._safe_strip(row.CT_Siret),
"tva_intra": self._safe_strip(row.CT_Identifiant),
"est_actif": (row.CT_Sommeil == 0),
"contact": self._safe_strip(row.CT_Contact),
"forme_juridique": self._safe_strip(row.CT_FormeJuridique),
}
except Exception as e:
logger.error(f" Erreur SQL fournisseur {code}: {e}")
return None
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
# ========================================
# ÉTAPE 0 : VALIDATION & NETTOYAGE
# ========================================
logger.info(" === VALIDATION DONNÉES FOURNISSEUR ===")
if not fournisseur_data.get("intitule"):
raise ValueError("Le champ 'intitule' est obligatoire")
# Nettoyage et troncature (longueurs max Sage)
intitule = str(fournisseur_data["intitule"])[:69].strip()
num_prop = (
str(fournisseur_data.get("num", "")).upper()[:17].strip()
if fournisseur_data.get("num")
else ""
)
compte = str(fournisseur_data.get("compte_collectif", "4010000"))[
:13
].strip()
adresse = str(fournisseur_data.get("adresse", ""))[:35].strip()
code_postal = str(fournisseur_data.get("code_postal", ""))[:9].strip()
ville = str(fournisseur_data.get("ville", ""))[:35].strip()
pays = str(fournisseur_data.get("pays", ""))[:35].strip()
telephone = str(fournisseur_data.get("telephone", ""))[:21].strip()
email = str(fournisseur_data.get("email", ""))[:69].strip()
siret = str(fournisseur_data.get("siret", ""))[:14].strip()
tva_intra = str(fournisseur_data.get("tva_intra", ""))[:25].strip()
logger.info(f" intitule: '{intitule}' (len={len(intitule)})")
logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})")
logger.info(f" compte: '{compte}' (len={len(compte)})")
# ========================================
# ÉTAPE 1 : CRÉATION OBJET FOURNISSEUR
# ========================================
# 🔑 CRITIQUE: Utiliser FactoryFournisseur, PAS FactoryClient !
factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur
persist = factory_fournisseur.Create()
fournisseur = win32com.client.CastTo(persist, "IBOFournisseur3")
# 🔑 CRITIQUE : Initialiser l'objet
fournisseur.SetDefault()
logger.info(" Objet fournisseur créé et initialisé")
# ========================================
# ÉTAPE 2 : CHAMPS OBLIGATOIRES
# ========================================
logger.info(" Définition des champs obligatoires...")
# 1. Intitulé (OBLIGATOIRE)
fournisseur.CT_Intitule = intitule
logger.debug(f" CT_Intitule: '{intitule}'")
# 2. Type = Fournisseur (1)
# NOTE: Sur certaines versions Sage, CT_Type n'existe pas
# et le type est automatiquement défini par la factory utilisée
try:
fournisseur.CT_Type = 1 # 1 = Fournisseur
logger.debug(" CT_Type: 1 (Fournisseur)")
except:
logger.debug(" CT_Type non défini (géré par FactoryFournisseur)")
# 3. Qualité (pour versions récentes Sage)
try:
fournisseur.CT_Qualite = "FOU"
logger.debug(" CT_Qualite: 'FOU'")
except:
logger.debug(" CT_Qualite non défini (pas critique)")
# 4. Compte général principal (OBLIGATOIRE)
try:
factory_compte = self.cial.CptaApplication.FactoryCompteG
persist_compte = factory_compte.ReadNumero(compte)
if persist_compte:
compte_obj = win32com.client.CastTo(
persist_compte, "IBOCompteG3"
)
compte_obj.Read()
# Assigner l'objet CompteG
fournisseur.CompteGPrinc = compte_obj
logger.debug(f" CompteGPrinc: objet '{compte}' assigné")
else:
logger.warning(
f" Compte {compte} introuvable - utilisation défaut"
)
except Exception as e:
logger.warning(f" Erreur CompteGPrinc: {e}")
# 5. Numéro fournisseur (OBLIGATOIRE - générer si vide)
if num_prop:
fournisseur.CT_Num = num_prop
logger.debug(f" CT_Num fourni: '{num_prop}'")
else:
# 🔑 CRITIQUE : Générer le numéro automatiquement
try:
# Méthode 1 : SetDefaultNumPiece (si disponible)
if hasattr(fournisseur, "SetDefaultNumPiece"):
fournisseur.SetDefaultNumPiece()
num_genere = getattr(fournisseur, "CT_Num", "")
logger.debug(f" CT_Num auto-généré: '{num_genere}'")
else:
# Méthode 2 : GetNextNumero depuis la factory
num_genere = factory_fournisseur.GetNextNumero()
if num_genere:
fournisseur.CT_Num = num_genere
logger.debug(
f" CT_Num auto (GetNextNumero): '{num_genere}'"
)
else:
# Méthode 3 : Fallback - timestamp
import time
num_genere = f"FOUR{int(time.time()) % 1000000}"
fournisseur.CT_Num = num_genere
logger.warning(f" CT_Num fallback: '{num_genere}'")
except Exception as e:
logger.error(f" Impossible de générer CT_Num: {e}")
raise ValueError(
"Impossible de générer le numéro fournisseur automatiquement"
)
# 6. Catégories (valeurs par défaut)
try:
if hasattr(fournisseur, "N_CatTarif"):
fournisseur.N_CatTarif = 1
if hasattr(fournisseur, "N_CatCompta"):
fournisseur.N_CatCompta = 1
if hasattr(fournisseur, "N_Period"):
fournisseur.N_Period = 1
logger.debug(" Catégories (N_*) initialisées")
except Exception as e:
logger.warning(f" Catégories: {e}")
# ========================================
# ÉTAPE 3 : CHAMPS OPTIONNELS
# ========================================
logger.info(" Définition champs optionnels...")
# Adresse (objet IAdresse)
if any([adresse, code_postal, ville, pays]):
try:
adresse_obj = fournisseur.Adresse
if adresse:
adresse_obj.Adresse = adresse
if code_postal:
adresse_obj.CodePostal = code_postal
if ville:
adresse_obj.Ville = ville
if pays:
adresse_obj.Pays = pays
logger.debug(" Adresse définie")
except Exception as e:
logger.warning(f" Adresse: {e}")
# Télécom (objet ITelecom)
if telephone or email:
try:
telecom_obj = fournisseur.Telecom
if telephone:
telecom_obj.Telephone = telephone
if email:
telecom_obj.EMail = email
logger.debug(" Télécom défini")
except Exception as e:
logger.warning(f" Télécom: {e}")
# Identifiants fiscaux
if siret:
try:
fournisseur.CT_Siret = siret
logger.debug(f" SIRET: '{siret}'")
except Exception as e:
logger.warning(f" SIRET: {e}")
if tva_intra:
try:
fournisseur.CT_Identifiant = tva_intra
logger.debug(f" TVA intra: '{tva_intra}'")
except Exception as e:
logger.warning(f" TVA: {e}")
# Options par défaut
try:
if hasattr(fournisseur, "CT_Lettrage"):
fournisseur.CT_Lettrage = True
if hasattr(fournisseur, "CT_Sommeil"):
fournisseur.CT_Sommeil = False
logger.debug(" Options par défaut définies")
except Exception as e:
logger.debug(f" Options: {e}")
# ========================================
# ÉTAPE 4 : VÉRIFICATION PRÉ-WRITE
# ========================================
logger.info(" === DIAGNOSTIC PRÉ-WRITE ===")
num_avant_write = getattr(fournisseur, "CT_Num", "")
if not num_avant_write:
logger.error(" CRITIQUE: CT_Num toujours vide !")
raise ValueError("Le numéro fournisseur (CT_Num) est obligatoire")
logger.info(f" CT_Num confirmé: '{num_avant_write}'")
# ========================================
# ÉTAPE 5 : ÉCRITURE EN BASE
# ========================================
logger.info(" Écriture du fournisseur dans Sage...")
try:
fournisseur.Write()
logger.info(" Write() réussi !")
except Exception as e:
error_detail = str(e)
# Récupérer l'erreur Sage détaillée
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = (
f"{sage_error.Description} (Code: {sage_error.Number})"
)
logger.error(f" Erreur Sage: {error_detail}")
except:
pass
# Analyser l'erreur
if (
"doublon" in error_detail.lower()
or "existe" in error_detail.lower()
):
raise ValueError(f"Ce fournisseur existe déjà : {error_detail}")
raise RuntimeError(f"Échec Write(): {error_detail}")
# ========================================
# ÉTAPE 6 : RELECTURE & FINALISATION
# ========================================
try:
fournisseur.Read()
except Exception as e:
logger.warning(f"Impossible de relire: {e}")
num_final = getattr(fournisseur, "CT_Num", "")
if not num_final:
raise RuntimeError("CT_Num vide après Write()")
logger.info(f" FOURNISSEUR CRÉÉ: {num_final} - {intitule} ")
# ========================================
# ÉTAPE 7 : CONSTRUCTION RÉPONSE
# ========================================
resultat = {
"numero": num_final,
"intitule": intitule,
"compte_collectif": compte,
"type": 1, # Fournisseur
"est_fournisseur": True,
"adresse": adresse or None,
"code_postal": code_postal or None,
"ville": ville or None,
"pays": pays or None,
"email": email or None,
"telephone": telephone or None,
"siret": siret or None,
"tva_intra": tva_intra or None,
}
# PAS DE REFRESH CACHE ICI
# Car lister_tous_fournisseurs() utilise FactoryFournisseur.List()
# qui lit directement depuis Sage (pas de cache)
return resultat
except ValueError as e:
logger.error(f" Erreur métier: {e}")
raise
except Exception as e:
logger.error(f" Erreur création fournisseur: {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage: {error_message}")
def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
# ========================================
# ÉTAPE 1 : CHARGER LE FOURNISSEUR EXISTANT
# ========================================
logger.info(f" Recherche fournisseur {code}...")
factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur
persist = factory_fournisseur.ReadNumero(code)
if not persist:
raise ValueError(f"Fournisseur {code} introuvable")
fournisseur = self._cast_client(persist) # Réutiliser _cast_client
if not fournisseur:
raise ValueError(f"Impossible de charger le fournisseur {code}")
logger.info(
f" Fournisseur {code} trouvé: {getattr(fournisseur, 'CT_Intitule', '')}"
)
# ========================================
# ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS
# ========================================
logger.info(" Mise à jour des champs...")
champs_modifies = []
# Intitulé
if "intitule" in fournisseur_data:
intitule = str(fournisseur_data["intitule"])[:69].strip()
fournisseur.CT_Intitule = intitule
champs_modifies.append(f"intitule='{intitule}'")
# Adresse
if any(
k in fournisseur_data
for k in ["adresse", "code_postal", "ville", "pays"]
):
try:
adresse_obj = fournisseur.Adresse
if "adresse" in fournisseur_data:
adresse = str(fournisseur_data["adresse"])[:35].strip()
adresse_obj.Adresse = adresse
champs_modifies.append("adresse")
if "code_postal" in fournisseur_data:
cp = str(fournisseur_data["code_postal"])[:9].strip()
adresse_obj.CodePostal = cp
champs_modifies.append("code_postal")
if "ville" in fournisseur_data:
ville = str(fournisseur_data["ville"])[:35].strip()
adresse_obj.Ville = ville
champs_modifies.append("ville")
if "pays" in fournisseur_data:
pays = str(fournisseur_data["pays"])[:35].strip()
adresse_obj.Pays = pays
champs_modifies.append("pays")
except Exception as e:
logger.warning(f"Erreur mise à jour adresse: {e}")
# Télécom
if "email" in fournisseur_data or "telephone" in fournisseur_data:
try:
telecom_obj = fournisseur.Telecom
if "email" in fournisseur_data:
email = str(fournisseur_data["email"])[:69].strip()
telecom_obj.EMail = email
champs_modifies.append("email")
if "telephone" in fournisseur_data:
tel = str(fournisseur_data["telephone"])[:21].strip()
telecom_obj.Telephone = tel
champs_modifies.append("telephone")
except Exception as e:
logger.warning(f"Erreur mise à jour télécom: {e}")
# SIRET
if "siret" in fournisseur_data:
try:
siret = str(fournisseur_data["siret"])[:14].strip()
fournisseur.CT_Siret = siret
champs_modifies.append("siret")
except Exception as e:
logger.warning(f"Erreur mise à jour SIRET: {e}")
# TVA Intracommunautaire
if "tva_intra" in fournisseur_data:
try:
tva = str(fournisseur_data["tva_intra"])[:25].strip()
fournisseur.CT_Identifiant = tva
champs_modifies.append("tva_intra")
except Exception as e:
logger.warning(f"Erreur mise à jour TVA: {e}")
if not champs_modifies:
logger.warning("Aucun champ à modifier")
# Retourner les données actuelles via extraction directe
return {
"numero": getattr(fournisseur, "CT_Num", "").strip(),
"intitule": getattr(fournisseur, "CT_Intitule", "").strip(),
"type": 1,
"est_fournisseur": True,
}
logger.info(f" Champs à modifier: {', '.join(champs_modifies)}")
# ========================================
# ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS
# ========================================
logger.info(" Écriture des modifications...")
try:
fournisseur.Write()
logger.info(" Write() réussi !")
except Exception as e:
error_detail = str(e)
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = (
f"{sage_error.Description} (Code: {sage_error.Number})"
)
except:
pass
logger.error(f" Erreur Write(): {error_detail}")
raise RuntimeError(f"Échec modification: {error_detail}")
# ========================================
# ÉTAPE 4 : RELIRE ET RETOURNER
# ========================================
fournisseur.Read()
logger.info(
f" FOURNISSEUR MODIFIÉ: {code} ({len(champs_modifies)} champs) "
)
# Extraction directe (comme lire_fournisseur)
numero = getattr(fournisseur, "CT_Num", "").strip()
intitule = getattr(fournisseur, "CT_Intitule", "").strip()
data = {
"numero": numero,
"intitule": intitule,
"type": 1,
"est_fournisseur": True,
}
# Adresse
try:
adresse_obj = getattr(fournisseur, "Adresse", None)
if adresse_obj:
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
data["code_postal"] = getattr(
adresse_obj, "CodePostal", ""
).strip()
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
except:
data["adresse"] = ""
data["code_postal"] = ""
data["ville"] = ""
# Télécom
try:
telecom_obj = getattr(fournisseur, "Telecom", None)
if telecom_obj:
data["telephone"] = getattr(
telecom_obj, "Telephone", ""
).strip()
data["email"] = getattr(telecom_obj, "EMail", "").strip()
except:
data["telephone"] = ""
data["email"] = ""
return data
except ValueError as e:
logger.error(f" Erreur métier: {e}")
raise
except Exception as e:
logger.error(f" Erreur modification fournisseur: {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage: {error_message}")
def lister_tous_clients(self, filtre=""):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = """
SELECT
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays,
CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant,
CT_Sommeil, CT_Prospect, CT_Contact
FROM F_COMPTET
WHERE CT_Type = 0
"""
params = []
if filtre:
query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)"
params.extend([f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY CT_Intitule"
cursor.execute(query, params)
rows = cursor.fetchall()
clients = []
for row in rows:
clients.append(
{
"numero": self._safe_strip(row.CT_Num),
"intitule": self._safe_strip(row.CT_Intitule),
"type": row.CT_Type,
"qualite": self._safe_strip(row.CT_Qualite),
"adresse": self._safe_strip(row.CT_Adresse),
"ville": self._safe_strip(row.CT_Ville),
"code_postal": self._safe_strip(row.CT_CodePostal),
"pays": self._safe_strip(row.CT_Pays),
"telephone": self._safe_strip(row.CT_Telephone),
"email": self._safe_strip(row.CT_EMail),
"siret": self._safe_strip(row.CT_Siret),
"tva_intra": self._safe_strip(row.CT_Identifiant),
"est_actif": (row.CT_Sommeil == 0),
"est_prospect": (row.CT_Prospect == 1),
"contact": self._safe_strip(row.CT_Contact),
}
)
logger.info(f" SQL: {len(clients)} clients")
return clients
except Exception as e:
logger.error(f" Erreur SQL clients: {e}")
raise RuntimeError(f"Erreur lecture clients: {str(e)}")
def lire_client(self, code_client):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# MÊME REQUÊTE que lister_tous_clients (colonnes existantes uniquement)
cursor.execute(
"""
SELECT
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays,
CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant,
CT_Sommeil, CT_Prospect, CT_Contact
FROM F_COMPTET
WHERE CT_Num = ?
""",
(code_client.upper(),),
)
row = cursor.fetchone()
if not row:
return None
return {
"numero": self._safe_strip(row[0]),
"intitule": self._safe_strip(row[1]),
"type": row[2],
"qualite": self._safe_strip(row[3]),
"adresse": self._safe_strip(row[4]),
"ville": self._safe_strip(row[5]),
"code_postal": self._safe_strip(row[6]),
"pays": self._safe_strip(row[7]),
"telephone": self._safe_strip(row[8]),
"email": self._safe_strip(row[9]),
"siret": self._safe_strip(row[10]),
"tva_intra": self._safe_strip(row[11]),
"est_actif": (row[12] == 0),
"est_prospect": (row[13] == 1),
"contact": self._safe_strip(row[14]),
}
except Exception as e:
logger.error(f" Erreur SQL client {code_client}: {e}")
return None
def lister_tous_articles(self, filtre="", avec_stock=True):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# ========================================
# ÉTAPE 1 : LIRE LES ARTICLES DE BASE
# ========================================
query = """
SELECT
AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch,
AR_UniteVen, FA_CodeFamille, AR_Sommeil,
AR_CodeBarre, AR_Type
FROM F_ARTICLE
WHERE 1=1
"""
params = []
if filtre:
query += " AND (AR_Ref LIKE ? OR AR_Design LIKE ?)"
params.extend([f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY AR_Ref"
cursor.execute(query, params)
rows = cursor.fetchall()
articles = []
for row in rows:
article = {
"reference": self._safe_strip(row[0]),
"designation": self._safe_strip(row[1]),
"prix_vente": float(row[2]) if row[2] is not None else 0.0,
"prix_achat": float(row[3]) if row[3] is not None else 0.0,
"unite_vente": (
str(row[4]).strip() if row[4] is not None else ""
),
"famille_code": self._safe_strip(row[5]),
"est_actif": (row[6] == 0),
"code_ean": self._safe_strip(row[7]),
"type_article": row[8] if row[8] is not None else 0,
# CORRECTION : Pas de AR_Stock dans ta base !
"stock_reel": 0.0,
"stock_mini": 0.0,
"stock_maxi": 0.0,
"stock_reserve": 0.0,
"stock_commande": 0.0,
"stock_disponible": 0.0,
}
article["code_barre"] = article["code_ean"]
articles.append(article)
# ========================================
# ÉTAPE 2 : ENRICHIR AVEC STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE)
# ========================================
if avec_stock and articles:
logger.info(
f" Enrichissement stock depuis F_ARTSTOCK pour {len(articles)} articles..."
)
try:
# Créer un mapping des références
references = [
a["reference"] for a in articles if a["reference"]
]
if not references:
return articles
# Requête pour récupérer TOUS les stocks en une fois
placeholders = ",".join(["?"] * len(references))
stock_query = f"""
SELECT
AR_Ref,
SUM(ISNULL(AS_QteSto, 0)) as Stock_Total,
MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini,
MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi,
SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve,
SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande
FROM F_ARTSTOCK
WHERE AR_Ref IN ({placeholders})
GROUP BY AR_Ref
"""
cursor.execute(stock_query, references)
stock_rows = cursor.fetchall()
stock_map = {}
for stock_row in stock_rows:
ref = self._safe_strip(stock_row[0])
if ref:
stock_map[ref] = {
"stock_reel": (
float(stock_row[1]) if stock_row[1] else 0.0
),
"stock_mini": (
float(stock_row[2]) if stock_row[2] else 0.0
),
"stock_maxi": (
float(stock_row[3]) if stock_row[3] else 0.0
),
"stock_reserve": (
float(stock_row[4]) if stock_row[4] else 0.0
),
"stock_commande": (
float(stock_row[5]) if stock_row[5] else 0.0
),
}
logger.info(
f" Stocks chargés pour {len(stock_map)} articles depuis F_ARTSTOCK"
)
# Enrichir les articles
for article in articles:
if article["reference"] in stock_map:
stock_data = stock_map[article["reference"]]
article.update(stock_data)
article["stock_disponible"] = (
article["stock_reel"] - article["stock_reserve"]
)
else:
# Article sans stock enregistré
article["stock_reel"] = 0.0
article["stock_mini"] = 0.0
article["stock_maxi"] = 0.0
article["stock_reserve"] = 0.0
article["stock_commande"] = 0.0
article["stock_disponible"] = 0.0
except Exception as e:
logger.error(
f" Erreur lecture F_ARTSTOCK: {e}", exc_info=True
)
# Ne pas lever d'exception, retourner les articles sans stock
logger.info(
f" SQL: {len(articles)} articles (stock={'oui' if avec_stock else 'non'})"
)
return articles
except Exception as e:
logger.error(f" Erreur SQL articles: {e}", exc_info=True)
raise RuntimeError(f"Erreur lecture articles: {str(e)}")
def lire_article(self, reference):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch,
AR_UniteVen, FA_CodeFamille, AR_Sommeil,
AR_CodeBarre, AR_Type
FROM F_ARTICLE
WHERE AR_Ref = ?
""",
(reference.upper(),),
)
row = cursor.fetchone()
if not row:
return None
article = {
"reference": self._safe_strip(row[0]),
"designation": self._safe_strip(row[1]),
"prix_vente": float(row[2]) if row[2] is not None else 0.0,
"prix_achat": float(row[3]) if row[3] is not None else 0.0,
"unite_vente": str(row[4]).strip() if row[4] is not None else "",
"famille_code": self._safe_strip(row[5]),
"est_actif": (row[6] == 0),
"code_ean": self._safe_strip(row[7]),
"code_barre": self._safe_strip(row[7]),
"type_article": row[8] if row[8] is not None else 0,
"type_article_libelle": {
0: "Article",
1: "Prestation",
2: "Divers",
}.get(row[8] if row[8] is not None else 0, "Article"),
# Champs optionnels (initialisés à vide/valeur par défaut)
"description": "",
"designation_complementaire": "",
"poids": 0.0,
"volume": 0.0,
"tva_code": "",
"date_creation": "",
"date_modification": "",
# Stock initialisé à 0 - sera mis à jour depuis F_ARTSTOCK
"stock_reel": 0.0,
"stock_mini": 0.0,
"stock_maxi": 0.0,
"stock_reserve": 0.0,
"stock_commande": 0.0,
"stock_disponible": 0.0,
}
# TVA taux (par défaut 20%)
article["tva_taux"] = 20.0
# ========================================
# ÉTAPE 2 : LIRE LE STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE)
# ========================================
logger.info(f" Lecture stock depuis F_ARTSTOCK pour {reference}...")
try:
cursor.execute(
"""
SELECT
SUM(ISNULL(AS_QteSto, 0)) as Stock_Total,
MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini,
MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi,
SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve,
SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande
FROM F_ARTSTOCK
WHERE AR_Ref = ?
GROUP BY AR_Ref
""",
(reference.upper(),),
)
stock_row = cursor.fetchone()
if stock_row:
# STOCK DEPUIS F_ARTSTOCK
article["stock_reel"] = (
float(stock_row[0]) if stock_row[0] else 0.0
)
article["stock_mini"] = (
float(stock_row[1]) if stock_row[1] else 0.0
)
article["stock_maxi"] = (
float(stock_row[2]) if stock_row[2] else 0.0
)
# Priorité aux réserves/commandes de F_ARTSTOCK si disponibles
stock_reserve_artstock = (
float(stock_row[3]) if stock_row[3] else 0.0
)
stock_commande_artstock = (
float(stock_row[4]) if stock_row[4] else 0.0
)
if stock_reserve_artstock > 0:
article["stock_reserve"] = stock_reserve_artstock
if stock_commande_artstock > 0:
article["stock_commande"] = stock_commande_artstock
article["stock_disponible"] = (
article["stock_reel"] - article["stock_reserve"]
)
logger.info(
f" Stock trouvé dans F_ARTSTOCK: {article['stock_reel']} unités"
)
else:
logger.info(
f"Aucun stock trouvé dans F_ARTSTOCK pour {reference}"
)
except Exception as e:
logger.error(f" Erreur lecture F_ARTSTOCK pour {reference}: {e}")
# ========================================
# ÉTAPE 3 : ENRICHIR AVEC LIBELLÉ FAMILLE
# ========================================
if article["famille_code"]:
try:
cursor.execute(
"SELECT FA_Intitule FROM F_FAMILLE WHERE FA_CodeFamille = ?",
(article["famille_code"],),
)
famille_row = cursor.fetchone()
if famille_row:
article["famille_libelle"] = self._safe_strip(
famille_row[0]
)
else:
article["famille_libelle"] = ""
except:
article["famille_libelle"] = ""
else:
article["famille_libelle"] = ""
return article
except Exception as e:
logger.error(f" Erreur SQL article {reference}: {e}")
return None
def _convertir_type_pour_sql(self, type_doc: int) -> int:
"""COM → SQL : 0, 10, 20, 30... → 0, 1, 2, 3..."""
mapping = {0: 0, 10: 1, 20: 2, 30: 3, 40: 4, 50: 5, 60: 6}
return mapping.get(type_doc, type_doc)
def _convertir_type_depuis_sql(self, type_sql: int) -> int:
"""SQL → COM : 0, 1, 2, 3... → 0, 10, 20, 30..."""
mapping = {0: 0, 1: 10, 2: 20, 3: 30, 4: 40, 5: 50, 6: 60}
return mapping.get(type_sql, type_sql)
def _lire_document_sql(self, numero: str, type_doc: int):
"""
Lit un document spécifique par son numéro.
PAS de filtre par préfixe car on cherche un document précis.
"""
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# ========================================
# LIRE L'ENTÊTE (recherche directe, sans filtres restrictifs)
# ========================================
query = """
SELECT
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.DO_Tiers, d.DO_DateLivr, d.DO_DateExpedition,
d.DO_Contact, d.DO_TotalHTNet, d.DO_NetAPayer,
d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte,
d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3,
d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3,
d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture,
d.DO_Transfere, d.DO_Souche, d.DO_PieceOrig, d.DO_GUID,
d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition,
d.DO_Tarif, d.DO_TypeFrais, d.DO_ValFrais,
d.DO_TypeFranco, d.DO_ValFranco,
c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal,
c.CT_Ville, c.CT_Telephone, c.CT_EMail
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
WHERE d.DO_Piece = ? AND d.DO_Type = ?
"""
logger.info(f"[SQL READ] Lecture directe de {numero} (type={type_doc})")
cursor.execute(query, (numero, type_doc))
row = cursor.fetchone()
if not row:
logger.warning(
f"[SQL READ] Document {numero} (type={type_doc}) INTROUVABLE dans F_DOCENTETE"
)
return None
numero_piece = self._safe_strip(row[0])
logger.info(f"[SQL READ] Document trouvé: {numero_piece}")
# ========================================
# PAS DE FILTRE PAR PRÉFIXE ICI !
# On retourne le document demandé, qu'il soit transformé ou non
# ========================================
# ========================================
# CONSTRUIRE L'OBJET DOCUMENT
# ========================================
doc = {
# Informations de base (indices 0-9)
"numero": numero_piece,
"reference": self._safe_strip(row[2]), # DO_Ref
"date": str(row[1]) if row[1] else "", # DO_Date
"date_livraison": (str(row[7]) if row[7] else ""), # DO_DateLivr
"date_expedition": (
str(row[8]) if row[8] else ""
), # DO_DateExpedition
# Client (indices 6 et 39-44)
"client_code": self._safe_strip(row[6]), # DO_Tiers
"client_intitule": self._safe_strip(row[39]), # CT_Intitule
"client_adresse": self._safe_strip(row[40]), # CT_Adresse
"client_code_postal": self._safe_strip(row[41]), # CT_CodePostal
"client_ville": self._safe_strip(row[42]), # CT_Ville
"client_telephone": self._safe_strip(row[43]), # CT_Telephone
"client_email": self._safe_strip(row[44]), # CT_EMail
"contact": self._safe_strip(row[9]), # DO_Contact
# Totaux (indices 3-4, 10-13)
"total_ht": float(row[3]) if row[3] else 0.0, # DO_TotalHT
"total_ht_net": float(row[10]) if row[10] else 0.0, # DO_TotalHTNet
"total_ttc": float(row[4]) if row[4] else 0.0, # DO_TotalTTC
"net_a_payer": float(row[11]) if row[11] else 0.0, # DO_NetAPayer
"montant_regle": (
float(row[12]) if row[12] else 0.0
), # DO_MontantRegle
"reliquat": float(row[13]) if row[13] else 0.0, # DO_Reliquat
# Taxes (indices 14-21)
"taux_escompte": (
float(row[14]) if row[14] else 0.0
), # DO_TxEscompte
"escompte": float(row[15]) if row[15] else 0.0, # DO_Escompte
"taxe1": float(row[16]) if row[16] else 0.0, # DO_Taxe1
"taxe2": float(row[17]) if row[17] else 0.0, # DO_Taxe2
"taxe3": float(row[18]) if row[18] else 0.0, # DO_Taxe3
"code_taxe1": self._safe_strip(row[19]), # DO_CodeTaxe1
"code_taxe2": self._safe_strip(row[20]), # DO_CodeTaxe2
"code_taxe3": self._safe_strip(row[21]), # DO_CodeTaxe3
# Statuts (indices 5, 22-26)
"statut": int(row[5]) if row[5] is not None else 0, # DO_Statut
"statut_estatut": (
int(row[22]) if row[22] is not None else 0
), # DO_EStatut
"imprime": int(row[23]) if row[23] is not None else 0, # DO_Imprim
"valide": int(row[24]) if row[24] is not None else 0, # DO_Valide
"cloture": int(row[25]) if row[25] is not None else 0, # DO_Cloture
"transfere": (
int(row[26]) if row[26] is not None else 0
), # DO_Transfere
# Autres (indices 27-38)
"souche": int(row[27]) if row[27] is not None else 0, # DO_Souche
"piece_origine": self._safe_strip(row[28]), # DO_PieceOrig
"guid": self._safe_strip(row[29]), # DO_GUID
"ca_num": self._safe_strip(row[30]), # CA_Num
"cg_num": self._safe_strip(row[31]), # CG_Num
"expedition": (
int(row[32]) if row[32] is not None else 1
), # DO_Expedit
"condition": (
int(row[33]) if row[33] is not None else 1
), # DO_Condition
"tarif": int(row[34]) if row[34] is not None else 1, # DO_Tarif
"type_frais": (
int(row[35]) if row[35] is not None else 0
), # DO_TypeFrais
"valeur_frais": float(row[36]) if row[36] else 0.0, # DO_ValFrais
"type_franco": (
int(row[37]) if row[37] is not None else 0
), # DO_TypeFranco
"valeur_franco": float(row[38]) if row[38] else 0.0, # DO_ValFranco
}
# ========================================
# CHARGER LES LIGNES
# ========================================
cursor.execute(
"""
SELECT
dl.*,
a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch,
a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd,
a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen,
a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie
FROM F_DOCLIGNE dl
LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref
WHERE dl.DO_Piece = ? AND dl.DO_Type = ?
ORDER BY dl.DL_Ligne
""",
(numero, type_doc),
)
lignes = []
for ligne_row in cursor.fetchall():
montant_ht = (
float(ligne_row.DL_MontantHT) if ligne_row.DL_MontantHT else 0.0
)
montant_net = (
float(ligne_row.DL_MontantNet)
if hasattr(ligne_row, "DL_MontantNet")
and ligne_row.DL_MontantNet
else montant_ht
)
taux_taxe1 = (
float(ligne_row.DL_Taxe1)
if hasattr(ligne_row, "DL_Taxe1") and ligne_row.DL_Taxe1
else 0.0
)
taux_taxe2 = (
float(ligne_row.DL_Taxe2)
if hasattr(ligne_row, "DL_Taxe2") and ligne_row.DL_Taxe2
else 0.0
)
taux_taxe3 = (
float(ligne_row.DL_Taxe3)
if hasattr(ligne_row, "DL_Taxe3") and ligne_row.DL_Taxe3
else 0.0
)
total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3
montant_ttc = montant_net * (1 + total_taux_taxes / 100)
montant_taxe1 = montant_net * (taux_taxe1 / 100)
montant_taxe2 = montant_net * (taux_taxe2 / 100)
montant_taxe3 = montant_net * (taux_taxe3 / 100)
ligne = {
"numero_ligne": (
int(ligne_row.DL_Ligne) if ligne_row.DL_Ligne else 0
),
"article_code": self._safe_strip(ligne_row.AR_Ref),
"designation": self._safe_strip(ligne_row.DL_Design),
"designation_article": self._safe_strip(ligne_row.AR_Design),
"quantite": (
float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0
),
"quantite_livree": (
float(ligne_row.DL_QteLiv)
if hasattr(ligne_row, "DL_QteLiv") and ligne_row.DL_QteLiv
else 0.0
),
"quantite_reservee": (
float(ligne_row.DL_QteRes)
if hasattr(ligne_row, "DL_QteRes") and ligne_row.DL_QteRes
else 0.0
),
"unite": (
self._safe_strip(ligne_row.DL_Unite)
if hasattr(ligne_row, "DL_Unite")
else ""
),
"prix_unitaire_ht": (
float(ligne_row.DL_PrixUnitaire)
if ligne_row.DL_PrixUnitaire
else 0.0
),
"prix_unitaire_achat": (
float(ligne_row.AR_PrixAch) if ligne_row.AR_PrixAch else 0.0
),
"prix_unitaire_vente": (
float(ligne_row.AR_PrixVen) if ligne_row.AR_PrixVen else 0.0
),
"prix_unitaire_ttc": (
float(ligne_row.AR_PrixTTC) if ligne_row.AR_PrixTTC else 0.0
),
"montant_ligne_ht": montant_ht,
"montant_ligne_net": montant_net,
"montant_ligne_ttc": montant_ttc,
"remise_valeur1": (
float(ligne_row.DL_Remise01REM_Valeur)
if hasattr(ligne_row, "DL_Remise01REM_Valeur")
and ligne_row.DL_Remise01REM_Valeur
else 0.0
),
"remise_type1": (
int(ligne_row.DL_Remise01REM_Type)
if hasattr(ligne_row, "DL_Remise01REM_Type")
and ligne_row.DL_Remise01REM_Type
else 0
),
"remise_valeur2": (
float(ligne_row.DL_Remise02REM_Valeur)
if hasattr(ligne_row, "DL_Remise02REM_Valeur")
and ligne_row.DL_Remise02REM_Valeur
else 0.0
),
"remise_type2": (
int(ligne_row.DL_Remise02REM_Type)
if hasattr(ligne_row, "DL_Remise02REM_Type")
and ligne_row.DL_Remise02REM_Type
else 0
),
"remise_article": (
float(ligne_row.AR_Escompte)
if ligne_row.AR_Escompte
else 0.0
),
"taux_taxe1": taux_taxe1,
"montant_taxe1": montant_taxe1,
"taux_taxe2": taux_taxe2,
"montant_taxe2": montant_taxe2,
"taux_taxe3": taux_taxe3,
"montant_taxe3": montant_taxe3,
"total_taxes": montant_taxe1 + montant_taxe2 + montant_taxe3,
"famille_article": self._safe_strip(ligne_row.FA_CodeFamille),
"gamme1": self._safe_strip(ligne_row.AR_Gamme1),
"gamme2": self._safe_strip(ligne_row.AR_Gamme2),
"code_barre": self._safe_strip(ligne_row.AR_CodeBarre),
"type_article": self._safe_strip(ligne_row.AR_Type),
"nature_article": self._safe_strip(ligne_row.AR_Nature),
"garantie": self._safe_strip(ligne_row.AR_Garantie),
"cout_standard": (
float(ligne_row.AR_CoutStd) if ligne_row.AR_CoutStd else 0.0
),
"poids_net": (
float(ligne_row.AR_PoidsNet)
if ligne_row.AR_PoidsNet
else 0.0
),
"poids_brut": (
float(ligne_row.AR_PoidsBrut)
if ligne_row.AR_PoidsBrut
else 0.0
),
"unite_vente": self._safe_strip(ligne_row.AR_UniteVen),
"date_livraison_ligne": (
str(ligne_row.DL_DateLivr)
if hasattr(ligne_row, "DL_DateLivr")
and ligne_row.DL_DateLivr
else ""
),
"statut_ligne": (
int(ligne_row.DL_Statut)
if hasattr(ligne_row, "DL_Statut")
and ligne_row.DL_Statut is not None
else 0
),
"depot": (
self._safe_strip(ligne_row.DE_No)
if hasattr(ligne_row, "DE_No")
else ""
),
"numero_commande": (
self._safe_strip(ligne_row.DL_NoColis)
if hasattr(ligne_row, "DL_NoColis")
else ""
),
"num_colis": (
self._safe_strip(ligne_row.DL_Colis)
if hasattr(ligne_row, "DL_Colis")
else ""
),
}
lignes.append(ligne)
doc["lignes"] = lignes
doc["nb_lignes"] = len(lignes)
# Totaux calculés
total_ht_calcule = sum(l.get("montant_ligne_ht", 0) for l in lignes)
total_ttc_calcule = sum(l.get("montant_ligne_ttc", 0) for l in lignes)
total_taxes_calcule = sum(l.get("total_taxes", 0) for l in lignes)
doc["total_ht_calcule"] = total_ht_calcule
doc["total_ttc_calcule"] = total_ttc_calcule
doc["total_taxes_calcule"] = total_taxes_calcule
return doc
except Exception as e:
logger.error(f" Erreur SQL lecture document {numero}: {e}", exc_info=True)
return None
def _lister_documents_avec_lignes_sql(
self,
type_doc: int,
filtre: str = "",
limit: int = None,
inclure_liaisons: bool = False,
calculer_transformations: bool = True,
):
"""Liste les documents avec leurs lignes."""
try:
# Convertir le type pour SQL
type_doc_sql = self._convertir_type_pour_sql(type_doc)
logger.info(f"[SQL LIST] ═══ Type COM {type_doc} → SQL {type_doc_sql} ═══")
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = """
SELECT DISTINCT
d.DO_Piece, d.DO_Type, d.DO_Date, d.DO_Ref, d.DO_Tiers,
d.DO_TotalHT, d.DO_TotalTTC, d.DO_NetAPayer, d.DO_Statut,
d.DO_DateLivr, d.DO_DateExpedition, d.DO_Contact, d.DO_TotalHTNet,
d.DO_MontantRegle, d.DO_Reliquat, d.DO_TxEscompte, d.DO_Escompte,
d.DO_Taxe1, d.DO_Taxe2, d.DO_Taxe3,
d.DO_CodeTaxe1, d.DO_CodeTaxe2, d.DO_CodeTaxe3,
d.DO_EStatut, d.DO_Imprim, d.DO_Valide, d.DO_Cloture, d.DO_Transfere,
d.DO_Souche, d.DO_PieceOrig, d.DO_GUID,
d.CA_Num, d.CG_Num, d.DO_Expedit, d.DO_Condition, d.DO_Tarif,
d.DO_TypeFrais, d.DO_ValFrais, d.DO_TypeFranco, d.DO_ValFranco,
c.CT_Intitule, c.CT_Adresse, c.CT_CodePostal,
c.CT_Ville, c.CT_Telephone, c.CT_EMail
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
WHERE d.DO_Type = ?
"""
params = [type_doc_sql]
if filtre:
query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ? OR d.DO_Ref LIKE ?)"
params.extend([f"%{filtre}%", f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY d.DO_Date DESC"
if limit:
query = f"SELECT TOP ({limit}) * FROM ({query}) AS subquery"
cursor.execute(query, params)
entetes = cursor.fetchall()
logger.info(f"[SQL LIST] {len(entetes)} documents SQL")
documents = []
stats = {
"total": len(entetes),
"exclus_prefixe": 0,
"erreur_construction": 0,
"erreur_lignes": 0,
"erreur_transformations": 0,
"erreur_liaisons": 0,
"succes": 0,
}
for idx, entete in enumerate(entetes):
numero = self._safe_strip(entete.DO_Piece)
logger.info(
f"[SQL LIST] [{idx+1}/{len(entetes)}] Traitement {numero}..."
)
try:
# ========================================
# ÉTAPE 1 : FILTRE PRÉFIXE
# ========================================
prefixes_vente = {
0: ["DE"],
10: ["BC"],
30: ["BL"],
50: ["AV", "AR"],
60: ["FA", "FC"],
}
prefixes_acceptes = prefixes_vente.get(type_doc, [])
if prefixes_acceptes:
est_vente = any(
numero.upper().startswith(p) for p in prefixes_acceptes
)
if not est_vente:
logger.info(
f"[SQL LIST] {numero} : exclu (préfixe achat)"
)
stats["exclus_prefixe"] += 1
continue
logger.debug(f"[SQL LIST] {numero} : préfixe OK")
# ========================================
# ÉTAPE 2 : CONSTRUIRE DOCUMENT DE BASE
# ========================================
try:
type_doc_depuis_sql = self._convertir_type_depuis_sql(
int(entete.DO_Type)
)
doc = {
"numero": numero,
"type": type_doc_depuis_sql,
"reference": self._safe_strip(entete.DO_Ref),
"date": str(entete.DO_Date) if entete.DO_Date else "",
"date_livraison": (
str(entete.DO_DateLivr)
if entete.DO_DateLivr
else ""
),
"date_expedition": (
str(entete.DO_DateExpedition)
if entete.DO_DateExpedition
else ""
),
"client_code": self._safe_strip(entete.DO_Tiers),
"client_intitule": self._safe_strip(entete.CT_Intitule),
"client_adresse": self._safe_strip(entete.CT_Adresse),
"client_code_postal": self._safe_strip(
entete.CT_CodePostal
),
"client_ville": self._safe_strip(entete.CT_Ville),
"client_telephone": self._safe_strip(
entete.CT_Telephone
),
"client_email": self._safe_strip(entete.CT_EMail),
"contact": self._safe_strip(entete.DO_Contact),
"total_ht": (
float(entete.DO_TotalHT)
if entete.DO_TotalHT
else 0.0
),
"total_ht_net": (
float(entete.DO_TotalHTNet)
if entete.DO_TotalHTNet
else 0.0
),
"total_ttc": (
float(entete.DO_TotalTTC)
if entete.DO_TotalTTC
else 0.0
),
"net_a_payer": (
float(entete.DO_NetAPayer)
if entete.DO_NetAPayer
else 0.0
),
"montant_regle": (
float(entete.DO_MontantRegle)
if entete.DO_MontantRegle
else 0.0
),
"reliquat": (
float(entete.DO_Reliquat)
if entete.DO_Reliquat
else 0.0
),
"taux_escompte": (
float(entete.DO_TxEscompte)
if entete.DO_TxEscompte
else 0.0
),
"escompte": (
float(entete.DO_Escompte)
if entete.DO_Escompte
else 0.0
),
"taxe1": (
float(entete.DO_Taxe1) if entete.DO_Taxe1 else 0.0
),
"taxe2": (
float(entete.DO_Taxe2) if entete.DO_Taxe2 else 0.0
),
"taxe3": (
float(entete.DO_Taxe3) if entete.DO_Taxe3 else 0.0
),
"code_taxe1": self._safe_strip(entete.DO_CodeTaxe1),
"code_taxe2": self._safe_strip(entete.DO_CodeTaxe2),
"code_taxe3": self._safe_strip(entete.DO_CodeTaxe3),
"statut": (
int(entete.DO_Statut)
if entete.DO_Statut is not None
else 0
),
"statut_estatut": (
int(entete.DO_EStatut)
if entete.DO_EStatut is not None
else 0
),
"imprime": (
int(entete.DO_Imprim)
if entete.DO_Imprim is not None
else 0
),
"valide": (
int(entete.DO_Valide)
if entete.DO_Valide is not None
else 0
),
"cloture": (
int(entete.DO_Cloture)
if entete.DO_Cloture is not None
else 0
),
"transfere": (
int(entete.DO_Transfere)
if entete.DO_Transfere is not None
else 0
),
"souche": self._safe_strip(entete.DO_Souche),
"piece_origine": self._safe_strip(entete.DO_PieceOrig),
"guid": self._safe_strip(entete.DO_GUID),
"ca_num": self._safe_strip(entete.CA_Num),
"cg_num": self._safe_strip(entete.CG_Num),
"expedition": self._safe_strip(entete.DO_Expedit),
"condition": self._safe_strip(entete.DO_Condition),
"tarif": self._safe_strip(entete.DO_Tarif),
"type_frais": (
int(entete.DO_TypeFrais)
if entete.DO_TypeFrais is not None
else 0
),
"valeur_frais": (
float(entete.DO_ValFrais)
if entete.DO_ValFrais
else 0.0
),
"type_franco": (
int(entete.DO_TypeFranco)
if entete.DO_TypeFranco is not None
else 0
),
"valeur_franco": (
float(entete.DO_ValFranco)
if entete.DO_ValFranco
else 0.0
),
"lignes": [],
}
logger.debug(
f"[SQL LIST] {numero} : document de base créé"
)
except Exception as e:
logger.error(
f"[SQL LIST] {numero} : ERREUR construction base: {e}",
exc_info=True,
)
stats["erreur_construction"] += 1
continue
# ========================================
# ÉTAPE 3 : CHARGER LES LIGNES
# ========================================
try:
cursor.execute(
"""
SELECT dl.*,
a.AR_Design, a.FA_CodeFamille, a.AR_PrixTTC, a.AR_PrixVen, a.AR_PrixAch,
a.AR_Gamme1, a.AR_Gamme2, a.AR_CodeBarre, a.AR_CoutStd,
a.AR_PoidsNet, a.AR_PoidsBrut, a.AR_UniteVen,
a.AR_Type, a.AR_Nature, a.AR_Escompte, a.AR_Garantie
FROM F_DOCLIGNE dl
LEFT JOIN F_ARTICLE a ON dl.AR_Ref = a.AR_Ref
WHERE dl.DO_Piece = ? AND dl.DO_Type = ?
ORDER BY dl.DL_Ligne
""",
(numero, type_doc_sql),
)
for ligne_row in cursor.fetchall():
montant_ht = (
float(ligne_row.DL_MontantHT)
if ligne_row.DL_MontantHT
else 0.0
)
montant_net = (
float(ligne_row.DL_MontantNet)
if hasattr(ligne_row, "DL_MontantNet")
and ligne_row.DL_MontantNet
else montant_ht
)
taux_taxe1 = (
float(ligne_row.DL_Taxe1)
if hasattr(ligne_row, "DL_Taxe1")
and ligne_row.DL_Taxe1
else 0.0
)
taux_taxe2 = (
float(ligne_row.DL_Taxe2)
if hasattr(ligne_row, "DL_Taxe2")
and ligne_row.DL_Taxe2
else 0.0
)
taux_taxe3 = (
float(ligne_row.DL_Taxe3)
if hasattr(ligne_row, "DL_Taxe3")
and ligne_row.DL_Taxe3
else 0.0
)
total_taux_taxes = taux_taxe1 + taux_taxe2 + taux_taxe3
montant_ttc = montant_net * (1 + total_taux_taxes / 100)
montant_taxe1 = montant_net * (taux_taxe1 / 100)
montant_taxe2 = montant_net * (taux_taxe2 / 100)
montant_taxe3 = montant_net * (taux_taxe3 / 100)
ligne = {
"numero_ligne": (
int(ligne_row.DL_Ligne)
if ligne_row.DL_Ligne
else 0
),
"article_code": self._safe_strip(ligne_row.AR_Ref),
"designation": self._safe_strip(
ligne_row.DL_Design
),
"designation_article": self._safe_strip(
ligne_row.AR_Design
),
"quantite": (
float(ligne_row.DL_Qte)
if ligne_row.DL_Qte
else 0.0
),
"quantite_livree": (
float(ligne_row.DL_QteLiv)
if hasattr(ligne_row, "DL_QteLiv")
and ligne_row.DL_QteLiv
else 0.0
),
"quantite_reservee": (
float(ligne_row.DL_QteRes)
if hasattr(ligne_row, "DL_QteRes")
and ligne_row.DL_QteRes
else 0.0
),
"unite": (
self._safe_strip(ligne_row.DL_Unite)
if hasattr(ligne_row, "DL_Unite")
else ""
),
"prix_unitaire_ht": (
float(ligne_row.DL_PrixUnitaire)
if ligne_row.DL_PrixUnitaire
else 0.0
),
"prix_unitaire_achat": (
float(ligne_row.AR_PrixAch)
if ligne_row.AR_PrixAch
else 0.0
),
"prix_unitaire_vente": (
float(ligne_row.AR_PrixVen)
if ligne_row.AR_PrixVen
else 0.0
),
"prix_unitaire_ttc": (
float(ligne_row.AR_PrixTTC)
if ligne_row.AR_PrixTTC
else 0.0
),
"montant_ligne_ht": montant_ht,
"montant_ligne_net": montant_net,
"montant_ligne_ttc": montant_ttc,
"remise_valeur1": (
float(ligne_row.DL_Remise01REM_Valeur)
if hasattr(ligne_row, "DL_Remise01REM_Valeur")
and ligne_row.DL_Remise01REM_Valeur
else 0.0
),
"remise_type1": (
int(ligne_row.DL_Remise01REM_Type)
if hasattr(ligne_row, "DL_Remise01REM_Type")
and ligne_row.DL_Remise01REM_Type
else 0
),
"remise_valeur2": (
float(ligne_row.DL_Remise02REM_Valeur)
if hasattr(ligne_row, "DL_Remise02REM_Valeur")
and ligne_row.DL_Remise02REM_Valeur
else 0.0
),
"remise_type2": (
int(ligne_row.DL_Remise02REM_Type)
if hasattr(ligne_row, "DL_Remise02REM_Type")
and ligne_row.DL_Remise02REM_Type
else 0
),
"remise_article": (
float(ligne_row.AR_Escompte)
if ligne_row.AR_Escompte
else 0.0
),
"taux_taxe1": taux_taxe1,
"montant_taxe1": montant_taxe1,
"taux_taxe2": taux_taxe2,
"montant_taxe2": montant_taxe2,
"taux_taxe3": taux_taxe3,
"montant_taxe3": montant_taxe3,
"total_taxes": montant_taxe1
+ montant_taxe2
+ montant_taxe3,
"famille_article": self._safe_strip(
ligne_row.FA_CodeFamille
),
"gamme1": self._safe_strip(ligne_row.AR_Gamme1),
"gamme2": self._safe_strip(ligne_row.AR_Gamme2),
"code_barre": self._safe_strip(
ligne_row.AR_CodeBarre
),
"type_article": self._safe_strip(ligne_row.AR_Type),
"nature_article": self._safe_strip(
ligne_row.AR_Nature
),
"garantie": self._safe_strip(ligne_row.AR_Garantie),
"cout_standard": (
float(ligne_row.AR_CoutStd)
if ligne_row.AR_CoutStd
else 0.0
),
"poids_net": (
float(ligne_row.AR_PoidsNet)
if ligne_row.AR_PoidsNet
else 0.0
),
"poids_brut": (
float(ligne_row.AR_PoidsBrut)
if ligne_row.AR_PoidsBrut
else 0.0
),
"unite_vente": self._safe_strip(
ligne_row.AR_UniteVen
),
"date_livraison_ligne": (
str(ligne_row.DL_DateLivr)
if hasattr(ligne_row, "DL_DateLivr")
and ligne_row.DL_DateLivr
else ""
),
"statut_ligne": (
int(ligne_row.DL_Statut)
if hasattr(ligne_row, "DL_Statut")
and ligne_row.DL_Statut is not None
else 0
),
"depot": (
self._safe_strip(ligne_row.DE_No)
if hasattr(ligne_row, "DE_No")
else ""
),
"numero_commande": (
self._safe_strip(ligne_row.DL_NoColis)
if hasattr(ligne_row, "DL_NoColis")
else ""
),
"num_colis": (
self._safe_strip(ligne_row.DL_Colis)
if hasattr(ligne_row, "DL_Colis")
else ""
),
}
doc["lignes"].append(ligne)
doc["nb_lignes"] = len(doc["lignes"])
doc["total_ht_calcule"] = sum(
l.get("montant_ligne_ht", 0) for l in doc["lignes"]
)
doc["total_ttc_calcule"] = sum(
l.get("montant_ligne_ttc", 0) for l in doc["lignes"]
)
doc["total_taxes_calcule"] = sum(
l.get("total_taxes", 0) for l in doc["lignes"]
)
logger.debug(
f"[SQL LIST] {numero} : {doc['nb_lignes']} lignes chargées"
)
except Exception as e:
logger.error(
f"[SQL LIST] {numero} : ERREUR lignes: {e}",
exc_info=True,
)
stats["erreur_lignes"] += 1
# Continuer quand même avec 0 lignes
# ========================================
# ÉTAPE 6 : AJOUT DU DOCUMENT
# ========================================
documents.append(doc)
stats["succes"] += 1
logger.info(
f"[SQL LIST] {numero} : AJOUTÉ à la liste (total: {len(documents)})"
)
except Exception as e:
logger.error(
f"[SQL LIST] {numero} : EXCEPTION GLOBALE - DOCUMENT EXCLU: {e}",
exc_info=True,
)
continue
# ========================================
# RÉSUMÉ FINAL
# ========================================
logger.info(f"[SQL LIST] ═══════════════════════════")
logger.info(f"[SQL LIST] STATISTIQUES FINALES:")
logger.info(f"[SQL LIST] Total SQL: {stats['total']}")
logger.info(f"[SQL LIST] Exclus préfixe: {stats['exclus_prefixe']}")
logger.info(
f"[SQL LIST] Erreur construction: {stats['erreur_construction']}"
)
logger.info(f"[SQL LIST] Erreur lignes: {stats['erreur_lignes']}")
logger.info(
f"[SQL LIST] Erreur transformations: {stats['erreur_transformations']}"
)
logger.info(f"[SQL LIST] Erreur liaisons: {stats['erreur_liaisons']}")
logger.info(f"[SQL LIST] SUCCÈS: {stats['succes']}")
logger.info(f"[SQL LIST] Documents retournés: {len(documents)}")
logger.info(f"[SQL LIST] ═══════════════════════════")
return documents
except Exception as e:
logger.error(f" Erreur GLOBALE listage: {e}", exc_info=True)
return []
def lister_tous_devis_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=0, filtre=filtre)
def lire_devis_cache(self, numero):
return self._lire_document_sql(numero, type_doc=0)
def lister_toutes_commandes_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=1, filtre=filtre)
def lire_commande_cache(self, numero):
return self._lire_document_sql(numero, type_doc=1)
def lister_toutes_factures_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=6, filtre=filtre)
def lire_facture_cache(self, numero):
return self._lire_document_sql(numero, type_doc=6)
def lister_tous_fournisseurs_cache(self, filtre=""):
return self.lister_tous_fournisseurs()
def lire_fournisseur_cache(self, code):
return self.lire_fournisseur()
def lister_toutes_livraisons_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=3, filtre=filtre)
def lire_livraison_cache(self, numero):
return self._lire_document_sql(numero, type_doc=3)
def lister_tous_avoirs_cache(self, filtre=""):
return self._lister_documents_avec_lignes_sql(type_doc=5, filtre=filtre)
def lire_avoir_cache(self, numero):
return self._lire_document_sql(numero, type_doc=5)
# =========================================================================
# CAST HELPERS
# =========================================================================
def _cast_client(self, persist_obj):
try:
obj = win32com.client.CastTo(persist_obj, "IBOClient3")
obj.Read()
return obj
except Exception as e:
logger.debug(f" _cast_client échoue: {e}") # AJOUTER CE LOG
return None
def _cast_article(self, persist_obj):
try:
obj = win32com.client.CastTo(persist_obj, "IBOArticle3")
obj.Read()
return obj
except:
return None
def _extraire_client(self, client_obj):
try:
# === 1. CHAMPS OBLIGATOIRES ===
try:
numero = getattr(client_obj, "CT_Num", "").strip()
if not numero:
logger.debug("Objet sans CT_Num, skip")
return None
except Exception as e:
logger.debug(f" Erreur lecture CT_Num: {e}")
return None
try:
intitule = getattr(client_obj, "CT_Intitule", "").strip()
if not intitule:
logger.debug(f"{numero} sans CT_Intitule")
except Exception as e:
logger.debug(f"Erreur CT_Intitule sur {numero}: {e}")
intitule = ""
# === 2. CONSTRUCTION OBJET DE BASE ===
data = {
"numero": numero,
"intitule": intitule,
}
# === 3. TYPE DE TIERS (CLIENT/PROSPECT/FOURNISSEUR) ===
# CT_Qualite : 0=Client, 1=Fournisseur, 2=Client+Fournisseur, 3=Salarié, 4=Prospect
try:
qualite_code = getattr(client_obj, "CT_Type", None)
# Mapper les codes vers des libellés
qualite_map = {
0: "CLI", # Client
1: "FOU", # Fournisseur
2: "CLIFOU", # Client + Fournisseur
3: "SAL", # Salarié
4: "PRO", # Prospect
}
data["qualite"] = qualite_map.get(qualite_code, "CLI")
data["est_fournisseur"] = qualite_code in [1, 2]
except:
data["qualite"] = "CLI"
data["est_fournisseur"] = False
# CT_Prospect : 0=Non, 1=Oui
try:
data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1
except:
data["est_prospect"] = False
# Déterminer le type_tiers principal
if data["est_prospect"]:
data["type_tiers"] = "prospect"
elif data["est_fournisseur"] and data["qualite"] != "CLIFOU":
data["type_tiers"] = "fournisseur"
elif data["qualite"] == "CLIFOU":
data["type_tiers"] = "client_fournisseur"
else:
data["type_tiers"] = "client"
# === 4. STATUT (ACTIF / SOMMEIL) ===
try:
sommeil = getattr(client_obj, "CT_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["est_en_sommeil"] = sommeil == 1
except:
data["est_actif"] = True
data["est_en_sommeil"] = False
# === 5. TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) ===
try:
forme_juridique = getattr(client_obj, "CT_FormeJuridique", "").strip()
data["forme_juridique"] = forme_juridique
data["est_entreprise"] = bool(forme_juridique)
data["est_particulier"] = not bool(forme_juridique)
except:
data["forme_juridique"] = ""
data["est_entreprise"] = False
data["est_particulier"] = True
# === 6. IDENTITÉ PERSONNE PHYSIQUE (SI PARTICULIER) ===
try:
data["civilite"] = getattr(client_obj, "CT_Civilite", "").strip()
except:
data["civilite"] = ""
try:
data["nom"] = getattr(client_obj, "CT_Nom", "").strip()
except:
data["nom"] = ""
try:
data["prenom"] = getattr(client_obj, "CT_Prenom", "").strip()
except:
data["prenom"] = ""
# Nom complet formaté (pour particuliers)
if data.get("nom") or data.get("prenom"):
parts = []
if data.get("civilite"):
parts.append(data["civilite"])
if data.get("prenom"):
parts.append(data["prenom"])
if data.get("nom"):
parts.append(data["nom"])
data["nom_complet"] = " ".join(parts)
else:
data["nom_complet"] = ""
# === 7. CONTACT PRINCIPAL ===
try:
data["contact"] = getattr(client_obj, "CT_Contact", "").strip()
except:
data["contact"] = ""
# === 8. ADRESSE COMPLÈTE ===
try:
adresse_obj = getattr(client_obj, "Adresse", None)
if adresse_obj:
try:
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
except:
data["adresse"] = ""
try:
data["complement"] = getattr(
adresse_obj, "Complement", ""
).strip()
except:
data["complement"] = ""
try:
data["code_postal"] = getattr(
adresse_obj, "CodePostal", ""
).strip()
except:
data["code_postal"] = ""
try:
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
except:
data["ville"] = ""
try:
data["region"] = getattr(adresse_obj, "Region", "").strip()
except:
data["region"] = ""
try:
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
except:
data["pays"] = ""
else:
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
except Exception as e:
logger.debug(f"Erreur adresse sur {numero}: {e}")
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
# === 9. TÉLÉCOMMUNICATIONS (DISTINCTION FIXE/MOBILE) ===
try:
telecom = getattr(client_obj, "Telecom", None)
if telecom:
# Téléphone FIXE
try:
data["telephone"] = getattr(telecom, "Telephone", "").strip()
except:
data["telephone"] = ""
# Téléphone MOBILE
try:
data["portable"] = getattr(telecom, "Portable", "").strip()
except:
data["portable"] = ""
# FAX
try:
data["telecopie"] = getattr(telecom, "Telecopie", "").strip()
except:
data["telecopie"] = ""
# EMAIL
try:
data["email"] = getattr(telecom, "EMail", "").strip()
except:
data["email"] = ""
# SITE WEB
try:
site = (
getattr(telecom, "Site", None)
or getattr(telecom, "Web", None)
or getattr(telecom, "SiteWeb", "")
)
data["site_web"] = str(site).strip() if site else ""
except:
data["site_web"] = ""
else:
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
except Exception as e:
logger.debug(f"Erreur telecom sur {numero}: {e}")
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
# === 10. INFORMATIONS JURIDIQUES (ENTREPRISES) ===
try:
data["siret"] = getattr(client_obj, "CT_Siret", "").strip()
except:
data["siret"] = ""
try:
data["siren"] = getattr(client_obj, "CT_Siren", "").strip()
except:
data["siren"] = ""
try:
data["tva_intra"] = getattr(client_obj, "CT_Identifiant", "").strip()
except:
data["tva_intra"] = ""
try:
data["code_naf"] = (
getattr(client_obj, "CT_CodeNAF", "").strip()
or getattr(client_obj, "CT_APE", "").strip()
)
except:
data["code_naf"] = ""
# === 11. INFORMATIONS COMMERCIALES ===
try:
data["secteur"] = getattr(client_obj, "CT_Secteur", "").strip()
except:
data["secteur"] = ""
try:
effectif = getattr(client_obj, "CT_Effectif", None)
data["effectif"] = int(effectif) if effectif is not None else None
except:
data["effectif"] = None
try:
ca = getattr(client_obj, "CT_ChiffreAffaire", None)
data["ca_annuel"] = float(ca) if ca is not None else None
except:
data["ca_annuel"] = None
# Commercial rattaché
try:
data["commercial_code"] = getattr(client_obj, "CO_No", "").strip()
except:
try:
data["commercial_code"] = getattr(
client_obj, "CT_Commercial", ""
).strip()
except:
data["commercial_code"] = ""
if data.get("commercial_code"):
try:
commercial_obj = getattr(client_obj, "Commercial", None)
if commercial_obj:
commercial_obj.Read()
data["commercial_nom"] = getattr(
commercial_obj, "CO_Nom", ""
).strip()
else:
data["commercial_nom"] = ""
except:
data["commercial_nom"] = ""
else:
data["commercial_nom"] = ""
# === 12. CATÉGORIES ===
try:
data["categorie_tarifaire"] = getattr(client_obj, "N_CatTarif", None)
except:
data["categorie_tarifaire"] = None
try:
data["categorie_comptable"] = getattr(client_obj, "N_CatCompta", None)
except:
data["categorie_comptable"] = None
# === 13. INFORMATIONS FINANCIÈRES ===
try:
data["encours_autorise"] = float(getattr(client_obj, "CT_Encours", 0.0))
except:
data["encours_autorise"] = 0.0
try:
data["assurance_credit"] = float(
getattr(client_obj, "CT_Assurance", 0.0)
)
except:
data["assurance_credit"] = 0.0
try:
data["compte_general"] = getattr(client_obj, "CG_Num", "").strip()
except:
data["compte_general"] = ""
# === 14. DATES ===
try:
date_creation = getattr(client_obj, "CT_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except:
data["date_creation"] = ""
try:
date_modif = getattr(client_obj, "CT_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f" ERREUR GLOBALE _extraire_client: {e}", exc_info=True)
return None
def _extraire_article(self, article_obj):
try:
data = {
# === IDENTIFICATION ===
"reference": getattr(article_obj, "AR_Ref", "").strip(),
"designation": getattr(article_obj, "AR_Design", "").strip(),
}
# === CODE EAN / CODE-BARRES ===
data["code_ean"] = ""
data["code_barre"] = ""
try:
# Essayer AR_CodeBarre (champ principal)
code_barre = getattr(article_obj, "AR_CodeBarre", "").strip()
if code_barre:
data["code_ean"] = code_barre
data["code_barre"] = code_barre
# Sinon essayer AR_CodeBarre1
if not data["code_ean"]:
code_barre1 = getattr(article_obj, "AR_CodeBarre1", "").strip()
if code_barre1:
data["code_ean"] = code_barre1
data["code_barre"] = code_barre1
except:
pass
# === PRIX ===
try:
data["prix_vente"] = float(getattr(article_obj, "AR_PrixVen", 0.0))
except:
data["prix_vente"] = 0.0
try:
data["prix_achat"] = float(getattr(article_obj, "AR_PrixAch", 0.0))
except:
data["prix_achat"] = 0.0
try:
data["prix_revient"] = float(
getattr(article_obj, "AR_PrixRevient", 0.0)
)
except:
data["prix_revient"] = 0.0
# === STOCK ===
try:
data["stock_reel"] = float(getattr(article_obj, "AR_Stock", 0.0))
except:
data["stock_reel"] = 0.0
try:
data["stock_mini"] = float(getattr(article_obj, "AR_StockMini", 0.0))
except:
data["stock_mini"] = 0.0
try:
data["stock_maxi"] = float(getattr(article_obj, "AR_StockMaxi", 0.0))
except:
data["stock_maxi"] = 0.0
# Stock réservé (en commande client)
try:
data["stock_reserve"] = float(getattr(article_obj, "AR_QteCom", 0.0))
except:
data["stock_reserve"] = 0.0
# Stock en commande fournisseur
try:
data["stock_commande"] = float(
getattr(article_obj, "AR_QteComFou", 0.0)
)
except:
data["stock_commande"] = 0.0
# Stock disponible (réel - réservé)
try:
data["stock_disponible"] = data["stock_reel"] - data["stock_reserve"]
except:
data["stock_disponible"] = data["stock_reel"]
# === DESCRIPTIONS ===
# Commentaire / Description détaillée
try:
commentaire = getattr(article_obj, "AR_Commentaire", "").strip()
data["description"] = commentaire
except:
data["description"] = ""
# Désignation complémentaire
try:
design2 = getattr(article_obj, "AR_Design2", "").strip()
data["designation_complementaire"] = design2
except:
data["designation_complementaire"] = ""
# === CLASSIFICATION ===
# Type d'article (0=Article, 1=Prestation, 2=Divers)
try:
type_art = getattr(article_obj, "AR_Type", 0)
data["type_article"] = type_art
data["type_article_libelle"] = {
0: "Article",
1: "Prestation",
2: "Divers",
}.get(type_art, "Inconnu")
except:
data["type_article"] = 0
data["type_article_libelle"] = "Article"
# Famille
try:
famille_code = getattr(article_obj, "FA_CodeFamille", "").strip()
data["famille_code"] = famille_code
# Charger le libellé de la famille si disponible
if famille_code:
try:
famille_obj = getattr(article_obj, "Famille", None)
if famille_obj:
famille_obj.Read()
data["famille_libelle"] = getattr(
famille_obj, "FA_Intitule", ""
).strip()
else:
data["famille_libelle"] = ""
except:
data["famille_libelle"] = ""
else:
data["famille_libelle"] = ""
except:
data["famille_code"] = ""
data["famille_libelle"] = ""
# === FOURNISSEUR PRINCIPAL ===
try:
fournisseur_code = getattr(article_obj, "CT_Num", "").strip()
data["fournisseur_principal"] = fournisseur_code
# Charger le nom du fournisseur si disponible
if fournisseur_code:
try:
fourn_obj = getattr(article_obj, "Fournisseur", None)
if fourn_obj:
fourn_obj.Read()
data["fournisseur_nom"] = getattr(
fourn_obj, "CT_Intitule", ""
).strip()
else:
data["fournisseur_nom"] = ""
except:
data["fournisseur_nom"] = ""
else:
data["fournisseur_nom"] = ""
except:
data["fournisseur_principal"] = ""
data["fournisseur_nom"] = ""
# === UNITÉS ===
try:
data["unite_vente"] = getattr(article_obj, "AR_UniteVen", "").strip()
except:
data["unite_vente"] = ""
try:
data["unite_achat"] = getattr(article_obj, "AR_UniteAch", "").strip()
except:
data["unite_achat"] = ""
# === CARACTÉRISTIQUES PHYSIQUES ===
try:
data["poids"] = float(getattr(article_obj, "AR_Poids", 0.0))
except:
data["poids"] = 0.0
try:
data["volume"] = float(getattr(article_obj, "AR_Volume", 0.0))
except:
data["volume"] = 0.0
# === STATUT ===
try:
sommeil = getattr(article_obj, "AR_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["en_sommeil"] = sommeil == 1
except:
data["est_actif"] = True
data["en_sommeil"] = False
# === TVA ===
try:
tva_code = getattr(article_obj, "TA_Code", "").strip()
data["tva_code"] = tva_code
# Essayer de charger le taux
try:
tva_obj = getattr(article_obj, "Taxe1", None)
if tva_obj:
tva_obj.Read()
data["tva_taux"] = float(getattr(tva_obj, "TA_Taux", 20.0))
else:
data["tva_taux"] = 20.0
except:
data["tva_taux"] = 20.0
except:
data["tva_code"] = ""
data["tva_taux"] = 20.0
# === DATES ===
try:
date_creation = getattr(article_obj, "AR_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except:
data["date_creation"] = ""
try:
date_modif = getattr(article_obj, "AR_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f" Erreur extraction article: {e}", exc_info=True)
# Retourner structure minimale en cas d'erreur
return {
"reference": getattr(article_obj, "AR_Ref", "").strip(),
"designation": getattr(article_obj, "AR_Design", "").strip(),
"prix_vente": 0.0,
"stock_reel": 0.0,
"code_ean": "",
"description": "",
"designation_complementaire": "",
"prix_achat": 0.0,
"prix_revient": 0.0,
"stock_mini": 0.0,
"stock_maxi": 0.0,
"stock_reserve": 0.0,
"stock_commande": 0.0,
"stock_disponible": 0.0,
"code_barre": "",
"type_article": 0,
"type_article_libelle": "Article",
"famille_code": "",
"famille_libelle": "",
"fournisseur_principal": "",
"fournisseur_nom": "",
"unite_vente": "",
"unite_achat": "",
"poids": 0.0,
"volume": 0.0,
"est_actif": True,
"en_sommeil": False,
"tva_code": "",
"tva_taux": 20.0,
"date_creation": "",
"date_modification": "",
}
def _extraire_fournisseur_enrichi(self, fourn_obj):
try:
# === IDENTIFICATION ===
numero = getattr(fourn_obj, "CT_Num", "").strip()
if not numero:
return None
intitule = getattr(fourn_obj, "CT_Intitule", "").strip()
data = {
"numero": numero,
"intitule": intitule,
"type": 1, # Fournisseur
"est_fournisseur": True,
}
# === STATUT ===
try:
sommeil = getattr(fourn_obj, "CT_Sommeil", 0)
data["est_actif"] = sommeil == 0
data["en_sommeil"] = sommeil == 1
except:
data["est_actif"] = True
data["en_sommeil"] = False
# === ADRESSE PRINCIPALE ===
try:
adresse_obj = getattr(fourn_obj, "Adresse", None)
if adresse_obj:
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
data["complement"] = getattr(adresse_obj, "Complement", "").strip()
data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip()
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
data["region"] = getattr(adresse_obj, "Region", "").strip()
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
# Adresse formatée complète
parties_adresse = []
if data["adresse"]:
parties_adresse.append(data["adresse"])
if data["complement"]:
parties_adresse.append(data["complement"])
if data["code_postal"] or data["ville"]:
ville_cp = f"{data['code_postal']} {data['ville']}".strip()
if ville_cp:
parties_adresse.append(ville_cp)
if data["pays"]:
parties_adresse.append(data["pays"])
data["adresse_complete"] = ", ".join(parties_adresse)
else:
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
data["adresse_complete"] = ""
except Exception as e:
logger.debug(f"Erreur adresse fournisseur {numero}: {e}")
data["adresse"] = ""
data["complement"] = ""
data["code_postal"] = ""
data["ville"] = ""
data["region"] = ""
data["pays"] = ""
data["adresse_complete"] = ""
# === TÉLÉCOMMUNICATIONS ===
try:
telecom_obj = getattr(fourn_obj, "Telecom", None)
if telecom_obj:
data["telephone"] = getattr(telecom_obj, "Telephone", "").strip()
data["portable"] = getattr(telecom_obj, "Portable", "").strip()
data["telecopie"] = getattr(telecom_obj, "Telecopie", "").strip()
data["email"] = getattr(telecom_obj, "EMail", "").strip()
# Site web
try:
site = (
getattr(telecom_obj, "Site", None)
or getattr(telecom_obj, "Web", None)
or getattr(telecom_obj, "SiteWeb", "")
)
data["site_web"] = str(site).strip() if site else ""
except:
data["site_web"] = ""
else:
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
except Exception as e:
logger.debug(f"Erreur telecom fournisseur {numero}: {e}")
data["telephone"] = ""
data["portable"] = ""
data["telecopie"] = ""
data["email"] = ""
data["site_web"] = ""
# === INFORMATIONS FISCALES ===
# SIRET
try:
data["siret"] = getattr(fourn_obj, "CT_Siret", "").strip()
except:
data["siret"] = ""
# SIREN (extrait du SIRET si disponible)
try:
if data["siret"] and len(data["siret"]) >= 9:
data["siren"] = data["siret"][:9]
else:
data["siren"] = getattr(fourn_obj, "CT_Siren", "").strip()
except:
data["siren"] = ""
# TVA Intracommunautaire
try:
data["tva_intra"] = getattr(fourn_obj, "CT_Identifiant", "").strip()
except:
data["tva_intra"] = ""
# Code NAF/APE
try:
data["code_naf"] = (
getattr(fourn_obj, "CT_CodeNAF", "").strip()
or getattr(fourn_obj, "CT_APE", "").strip()
)
except:
data["code_naf"] = ""
# Forme juridique
try:
data["forme_juridique"] = getattr(
fourn_obj, "CT_FormeJuridique", ""
).strip()
except:
data["forme_juridique"] = ""
# === CATÉGORIES ===
# Catégorie tarifaire
try:
cat_tarif = getattr(fourn_obj, "N_CatTarif", None)
data["categorie_tarifaire"] = (
int(cat_tarif) if cat_tarif is not None else None
)
except:
data["categorie_tarifaire"] = None
# Catégorie comptable
try:
cat_compta = getattr(fourn_obj, "N_CatCompta", None)
data["categorie_comptable"] = (
int(cat_compta) if cat_compta is not None else None
)
except:
data["categorie_comptable"] = None
# === CONDITIONS DE RÈGLEMENT ===
try:
cond_regl = getattr(fourn_obj, "CT_CondRegl", "").strip()
data["conditions_reglement_code"] = cond_regl
# Charger le libellé si disponible
if cond_regl:
try:
# Essayer de charger l'objet ConditionReglement
cond_obj = getattr(fourn_obj, "ConditionReglement", None)
if cond_obj:
cond_obj.Read()
data["conditions_reglement_libelle"] = getattr(
cond_obj, "C_Intitule", ""
).strip()
else:
data["conditions_reglement_libelle"] = ""
except:
data["conditions_reglement_libelle"] = ""
else:
data["conditions_reglement_libelle"] = ""
except:
data["conditions_reglement_code"] = ""
data["conditions_reglement_libelle"] = ""
# Mode de règlement
try:
mode_regl = getattr(fourn_obj, "CT_ModeRegl", "").strip()
data["mode_reglement_code"] = mode_regl
# Libellé du mode de règlement
if mode_regl:
try:
mode_obj = getattr(fourn_obj, "ModeReglement", None)
if mode_obj:
mode_obj.Read()
data["mode_reglement_libelle"] = getattr(
mode_obj, "M_Intitule", ""
).strip()
else:
data["mode_reglement_libelle"] = ""
except:
data["mode_reglement_libelle"] = ""
else:
data["mode_reglement_libelle"] = ""
except:
data["mode_reglement_code"] = ""
data["mode_reglement_libelle"] = ""
# === COORDONNÉES BANCAIRES (IBAN) ===
data["coordonnees_bancaires"] = []
try:
# Sage peut avoir plusieurs comptes bancaires
factory_banque = getattr(fourn_obj, "FactoryBanque", None)
if factory_banque:
index = 1
while index <= 5: # Max 5 comptes bancaires
try:
banque_persist = factory_banque.List(index)
if banque_persist is None:
break
banque = win32com.client.CastTo(
banque_persist, "IBOBanque3"
)
banque.Read()
compte_bancaire = {
"banque_nom": getattr(
banque, "BI_Intitule", ""
).strip(),
"iban": getattr(banque, "RIB_Iban", "").strip(),
"bic": getattr(banque, "RIB_Bic", "").strip(),
"code_banque": getattr(
banque, "RIB_Banque", ""
).strip(),
"code_guichet": getattr(
banque, "RIB_Guichet", ""
).strip(),
"numero_compte": getattr(
banque, "RIB_Compte", ""
).strip(),
"cle_rib": getattr(banque, "RIB_Cle", "").strip(),
}
# Ne garder que si IBAN ou RIB complet
if (
compte_bancaire["iban"]
or compte_bancaire["numero_compte"]
):
data["coordonnees_bancaires"].append(compte_bancaire)
index += 1
except:
break
except Exception as e:
logger.debug(
f"Erreur coordonnées bancaires fournisseur {numero}: {e}"
)
# IBAN principal (premier de la liste)
if data["coordonnees_bancaires"]:
data["iban_principal"] = data["coordonnees_bancaires"][0].get(
"iban", ""
)
data["bic_principal"] = data["coordonnees_bancaires"][0].get("bic", "")
else:
data["iban_principal"] = ""
data["bic_principal"] = ""
# === CONTACTS ===
data["contacts"] = []
try:
factory_contact = getattr(fourn_obj, "FactoryContact", None)
if factory_contact:
index = 1
while index <= 20: # Max 20 contacts
try:
contact_persist = factory_contact.List(index)
if contact_persist is None:
break
contact = win32com.client.CastTo(
contact_persist, "IBOContact3"
)
contact.Read()
contact_data = {
"nom": getattr(contact, "CO_Nom", "").strip(),
"prenom": getattr(contact, "CO_Prenom", "").strip(),
"fonction": getattr(contact, "CO_Fonction", "").strip(),
"service": getattr(contact, "CO_Service", "").strip(),
"telephone": getattr(
contact, "CO_Telephone", ""
).strip(),
"portable": getattr(contact, "CO_Portable", "").strip(),
"email": getattr(contact, "CO_EMail", "").strip(),
}
# Nom complet formaté
nom_complet = f"{contact_data['prenom']} {contact_data['nom']}".strip()
if nom_complet:
contact_data["nom_complet"] = nom_complet
else:
contact_data["nom_complet"] = contact_data["nom"]
# Ne garder que si nom existe
if contact_data["nom"]:
data["contacts"].append(contact_data)
index += 1
except:
break
except Exception as e:
logger.debug(f"Erreur contacts fournisseur {numero}: {e}")
# Nombre de contacts
data["nb_contacts"] = len(data["contacts"])
# Contact principal (premier de la liste)
if data["contacts"]:
data["contact_principal"] = data["contacts"][0]
else:
data["contact_principal"] = None
# === STATISTIQUES & INFORMATIONS COMMERCIALES ===
# Encours autorisé
try:
data["encours_autorise"] = float(getattr(fourn_obj, "CT_Encours", 0.0))
except:
data["encours_autorise"] = 0.0
# Chiffre d'affaires annuel
try:
data["ca_annuel"] = float(getattr(fourn_obj, "CT_ChiffreAffaire", 0.0))
except:
data["ca_annuel"] = 0.0
# Compte général
try:
data["compte_general"] = getattr(fourn_obj, "CG_Num", "").strip()
except:
data["compte_general"] = ""
# === DATES ===
try:
date_creation = getattr(fourn_obj, "CT_DateCreate", None)
data["date_creation"] = str(date_creation) if date_creation else ""
except:
data["date_creation"] = ""
try:
date_modif = getattr(fourn_obj, "CT_DateModif", None)
data["date_modification"] = str(date_modif) if date_modif else ""
except:
data["date_modification"] = ""
return data
except Exception as e:
logger.error(f" Erreur extraction fournisseur: {e}", exc_info=True)
# Retourner structure minimale en cas d'erreur
return {
"numero": getattr(fourn_obj, "CT_Num", "").strip(),
"intitule": getattr(fourn_obj, "CT_Intitule", "").strip(),
"type": 1,
"est_fournisseur": True,
"est_actif": True,
"en_sommeil": False,
"adresse": "",
"complement": "",
"code_postal": "",
"ville": "",
"region": "",
"pays": "",
"adresse_complete": "",
"telephone": "",
"portable": "",
"telecopie": "",
"email": "",
"site_web": "",
"siret": "",
"siren": "",
"tva_intra": "",
"code_naf": "",
"forme_juridique": "",
"categorie_tarifaire": None,
"categorie_comptable": None,
"conditions_reglement_code": "",
"conditions_reglement_libelle": "",
"mode_reglement_code": "",
"mode_reglement_libelle": "",
"iban_principal": "",
"bic_principal": "",
"coordonnees_bancaires": [],
"contacts": [],
"nb_contacts": 0,
"contact_principal": None,
"encours_autorise": 0.0,
"ca_annuel": 0.0,
"compte_general": "",
"date_creation": "",
"date_modification": "",
}
def normaliser_date(self, valeur):
if isinstance(valeur, str):
try:
return datetime.fromisoformat(valeur)
except ValueError:
return datetime.now()
elif isinstance(valeur, date):
return datetime.combine(valeur, datetime.min.time())
elif isinstance(valeur, datetime):
return valeur
else:
return datetime.now()
def creer_devis_enrichi(self, devis_data: dict, forcer_brouillon: bool = False):
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
logger.info(
f" Début création devis pour client {devis_data['client']['code']} "
f"(brouillon={forcer_brouillon})"
)
try:
with self._com_context(), self._lock_com:
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug(" Transaction Sage démarrée")
except Exception as e:
logger.warning(f"BeginTrans échoué: {e}")
try:
# ===== CRÉATION DOCUMENT =====
process = self.cial.CreateProcess_Document(0)
doc = process.Document
try:
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
except:
pass
logger.info(" Document devis créé")
# ===== DATES =====
doc.DO_Date = pywintypes.Time(
self.normaliser_date(devis_data.get("date_devis"))
)
if "date_livraison" in devis_data and devis_data["date_livraison"]:
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(devis_data["date_livraison"])
)
# ===== 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)
logger.info(f" Client {devis_data['client']['code']} associé")
# ===== STATUT =====
if forcer_brouillon:
doc.DO_Statut = 0
logger.info(" Statut défini: 0 (Brouillon)")
else:
doc.DO_Statut = 2
logger.info(" Statut défini: 2 (Accepté)")
doc.Write()
# ===== 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 =====
if not forcer_brouillon:
logger.info(" Lancement Process()...")
process.Process()
else:
try:
process.Process()
logger.info(" Process() appelé (brouillon)")
except:
logger.debug("Process() ignoré pour brouillon")
# ===== RÉCUPÉRATION NUMÉRO =====
numero_devis = self._recuperer_numero_devis(process, doc)
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
# ===== ATTENTE POUR STABILISATION =====
import time
time.sleep(0.5)
# ===== RÉFÉRENCE (RECHARGER D'ABORD LE DOCUMENT) =====
if "reference" in devis_data and devis_data["reference"]:
try:
logger.info(
f" Application de la référence: {devis_data['reference']}"
)
# RECHARGER le document par son numéro
doc_reload = self._charger_devis(numero_devis)
# Appliquer la référence
nouvelle_reference = devis_data["reference"]
doc_reload.DO_Ref = (
str(nouvelle_reference) if nouvelle_reference else ""
)
doc_reload.Write()
time.sleep(0.5)
doc_reload.Read()
logger.info(f" Référence définie: {nouvelle_reference}")
except Exception as e:
logger.warning(
f"Impossible de définir la référence: {e}",
exc_info=True,
)
# ===== RELECTURE FINALE =====
time.sleep(0.5)
doc_final_data = self._relire_devis(
numero_devis, devis_data, forcer_brouillon
)
logger.info(
f" DEVIS CRÉÉ: {numero_devis} - {doc_final_data['total_ttc']}€ TTC "
)
return doc_final_data
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
logger.error(" Transaction annulée (rollback)")
except:
pass
raise
except Exception as e:
logger.error(f" ERREUR CRÉATION DEVIS: {e}", exc_info=True)
raise RuntimeError(f"Échec création devis: {str(e)}")
def _recuperer_numero_devis(self, process, doc):
"""Récupère le numéro du devis créé via plusieurs méthodes."""
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
return numero_devis
def _relire_devis(self, numero_devis, devis_data, forcer_brouillon):
"""Relit le devis créé et extrait les informations finales."""
factory_doc = self.cial.FactoryDocumentVente
persist_reread = factory_doc.ReadPiece(0, numero_devis)
if not persist_reread:
logger.debug("ReadPiece échoué, recherche dans List()...")
persist_reread = self._rechercher_devis_dans_liste(
numero_devis, factory_doc
)
# Extraction des informations
if persist_reread:
doc_final = win32com.client.CastTo(persist_reread, "IBODocumentVente3")
doc_final.Read()
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
statut_final = getattr(doc_final, "DO_Statut", 0)
reference_final = getattr(doc_final, "DO_Ref", "")
# Dates
date_livraison_final = None
try:
date_livr = getattr(doc_final, "DO_DateLivr", None)
if date_livr:
date_livraison_final = date_livr.strftime("%Y-%m-%d")
except:
pass
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
reference_final = devis_data.get("reference", "")
date_livraison_final = devis_data.get("date_livraison")
logger.info(f" Total HT: {total_ht}")
logger.info(f" Total TTC: {total_ttc}")
logger.info(f" Statut final: {statut_final}")
if reference_final:
logger.info(f" Référence: {reference_final}")
if date_livraison_final:
logger.info(f" Date livraison: {date_livraison_final}")
return {
"numero_devis": numero_devis,
"total_ht": total_ht,
"total_ttc": total_ttc,
"nb_lignes": len(devis_data["lignes"]),
"client_code": devis_data["client"]["code"],
"date_devis": str(devis_data.get("date_devis", "")),
"date_livraison": date_livraison_final,
"reference": reference_final,
"statut": statut_final,
}
def _rechercher_devis_dans_liste(self, numero_devis, factory_doc):
"""Recherche un devis dans les 100 premiers éléments de la liste."""
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
):
logger.info(f" Document trouvé à l'index {index}")
return persist_test
index += 1
except:
index += 1
return None
def modifier_devis(self, numero: str, devis_data: Dict) -> Dict:
"""
Modifie un devis existant dans Sage - VERSION COMPLÈTE.
Args:
numero: Numéro du devis
devis_data: dict contenant les champs à modifier:
- date_devis: str ou date (optionnel)
- date_livraison: str ou date (optionnel)
- reference: str (optionnel)
- statut: int (optionnel)
- lignes: list[dict] (optionnel)
"""
# ========================================
# LOG INITIAL CRITIQUE - POUR VÉRIFIER QUE LA MÉTHODE EST APPELÉE
# ========================================
logger.info("=" * 100)
logger.info("=" * 100)
logger.info(f" NOUVELLE MÉTHODE modifier_devis() APPELÉE POUR {numero} ")
logger.info(f" Données reçues: {devis_data}")
logger.info("=" * 100)
if not self.cial:
logger.error(" Connexion Sage non établie")
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
logger.info("")
logger.info("=" * 80)
logger.info(f" [ÉTAPE 1] CHARGEMENT DU DEVIS {numero}")
logger.info("=" * 80)
# Charger le devis
doc = self._charger_devis(numero)
logger.info(f" Devis {numero} chargé avec succès")
# Afficher l'état INITIAL
logger.info("")
self._afficher_etat_document(doc, "📸 ÉTAT INITIAL")
# Vérifier qu'il n'est pas transformé
logger.info(" Vérification statut transformation...")
self._verifier_devis_non_transforme(numero, doc)
logger.info(" Devis non transformé - modification autorisée")
# ========================================
# ÉTAPE 2 : INFORMATIONS CLIENT ET LIGNES
# ========================================
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 2] ANALYSE DOCUMENT ACTUEL")
logger.info("=" * 80)
# Client
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: {client_code_initial}")
else:
logger.warning(" Objet Client non trouvé")
except Exception as e:
logger.warning(f" Impossible de lire le client: {e}")
# Compter lignes
nb_lignes_initial = self._compter_lignes_document(doc)
logger.info(f" Nombre de lignes actuelles: {nb_lignes_initial}")
# ========================================
# ÉTAPE 3 : ANALYSE DES MODIFICATIONS DEMANDÉES
# ========================================
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 3] ANALYSE MODIFICATIONS DEMANDÉES")
logger.info("=" * 80)
modif_date = "date_devis" in devis_data
modif_date_livraison = "date_livraison" in devis_data
modif_statut = "statut" in devis_data
modif_ref = "reference" in devis_data
modif_lignes = "lignes" in devis_data and devis_data["lignes"] is not None
logger.info(f" Date devis: {modif_date}")
if modif_date:
logger.info(f" → Valeur: {devis_data['date_devis']}")
logger.info(f" Date livraison: {modif_date_livraison}")
if modif_date_livraison:
logger.info(f" → Valeur: {devis_data['date_livraison']}")
logger.info(f" Référence: {modif_ref}")
if modif_ref:
logger.info(f" → Valeur: '{devis_data['reference']}'")
logger.info(f" Statut: {modif_statut}")
if modif_statut:
logger.info(f" → Valeur: {devis_data['statut']}")
logger.info(f" Lignes: {modif_lignes}")
if modif_lignes:
logger.info(f" → Nombre: {len(devis_data['lignes'])}")
for i, ligne in enumerate(devis_data['lignes'], 1):
logger.info(f" → Ligne {i}: {ligne.get('article_code')} (Qté: {ligne.get('quantite')})")
# Reporter référence et statut après lignes
devis_data_temp = devis_data.copy()
reference_a_modifier = None
statut_a_modifier = None
if modif_lignes:
logger.info("")
logger.info(" STRATÉGIE: Report référence/statut APRÈS modification lignes")
if modif_ref:
reference_a_modifier = devis_data_temp.pop("reference")
logger.info(f" Référence '{reference_a_modifier}' reportée")
modif_ref = False
if modif_statut:
statut_a_modifier = devis_data_temp.pop("statut")
logger.info(f" Statut {statut_a_modifier} reporté")
modif_statut = False
# ========================================
# ÉTAPE 4 : TEST WRITE() BASIQUE
# ========================================
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 4] TEST WRITE() BASIQUE")
logger.info("=" * 80)
logger.info("Test sans modification pour vérifier le verrouillage...")
try:
doc.Write()
logger.info(" Write() basique OK - Document NON verrouillé")
time.sleep(0.3)
doc.Read()
logger.info(" Read() après Write() OK")
except Exception as e:
logger.error(f" Write() basique ÉCHOUE: {e}")
logger.error(" ABANDON: Document VERROUILLÉ ou problème COM")
raise ValueError(f"Document verrouillé: {e}")
champs_modifies = []
# ========================================
# ÉTAPE 5 : MODIFICATIONS SIMPLES (sans lignes)
# ========================================
if not modif_lignes and (modif_date or modif_date_livraison or modif_statut or modif_ref):
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 5A] MODIFICATIONS SIMPLES (sans lignes)")
logger.info("=" * 80)
# Date devis
if modif_date:
logger.info("")
logger.info(" Modification DATE_DEVIS...")
try:
ancienne_date = getattr(doc, "DO_Date", None)
ancienne_date_str = ancienne_date.strftime("%Y-%m-%d") if ancienne_date else "None"
logger.info(f" Actuelle: {ancienne_date_str}")
nouvelle_date = self.normaliser_date(devis_data_temp["date_devis"])
nouvelle_date_str = nouvelle_date.strftime("%Y-%m-%d")
logger.info(f" Cible: {nouvelle_date_str}")
doc.DO_Date = pywintypes.Time(nouvelle_date)
logger.info(" ✓ doc.DO_Date affecté")
champs_modifies.append("date_devis")
logger.info(f" Date devis sera modifiée: {ancienne_date_str}{nouvelle_date_str}")
except Exception as e:
logger.error(f" Erreur date devis: {e}", exc_info=True)
# Date livraison
if modif_date_livraison:
logger.info("")
logger.info(" Modification DATE_LIVRAISON...")
try:
ancienne_date_livr = getattr(doc, "DO_DateLivr", None)
ancienne_date_livr_str = ancienne_date_livr.strftime("%Y-%m-%d") if ancienne_date_livr else "None"
logger.info(f" Actuelle: {ancienne_date_livr_str}")
if devis_data_temp["date_livraison"]:
nouvelle_date_livr = self.normaliser_date(devis_data_temp["date_livraison"])
nouvelle_date_livr_str = nouvelle_date_livr.strftime("%Y-%m-%d")
logger.info(f" Cible: {nouvelle_date_livr_str}")
doc.DO_DateLivr = pywintypes.Time(nouvelle_date_livr)
logger.info(" ✓ doc.DO_DateLivr affecté")
else:
logger.info(" Cible: Effacement (None)")
doc.DO_DateLivr = None
logger.info(" ✓ doc.DO_DateLivr = None")
champs_modifies.append("date_livraison")
logger.info(" Date livraison sera modifiée")
except Exception as e:
logger.error(f" Erreur date livraison: {e}", exc_info=True)
# Référence
if modif_ref:
logger.info("")
logger.info(" Modification RÉFÉRENCE...")
try:
ancienne_ref = getattr(doc, "DO_Ref", "")
logger.info(f" Actuelle: '{ancienne_ref}'")
nouvelle_ref = str(devis_data_temp["reference"]) if devis_data_temp["reference"] else ""
logger.info(f" Cible: '{nouvelle_ref}'")
doc.DO_Ref = nouvelle_ref
logger.info(" ✓ doc.DO_Ref affecté")
champs_modifies.append("reference")
logger.info(f" Référence sera modifiée: '{ancienne_ref}''{nouvelle_ref}'")
except Exception as e:
logger.error(f" Erreur référence: {e}", exc_info=True)
# Statut
if modif_statut:
logger.info("")
logger.info(" Modification STATUT...")
try:
statut_actuel = getattr(doc, "DO_Statut", 0)
logger.info(f" Actuel: {statut_actuel}")
nouveau_statut = int(devis_data_temp["statut"])
logger.info(f" Cible: {nouveau_statut}")
if nouveau_statut in [0, 1, 2, 3]:
doc.DO_Statut = nouveau_statut
logger.info(" ✓ doc.DO_Statut affecté")
champs_modifies.append("statut")
logger.info(f" Statut sera modifié: {statut_actuel}{nouveau_statut}")
else:
logger.warning(f" Statut {nouveau_statut} invalide (doit être 0,1,2 ou 3)")
except Exception as e:
logger.error(f" Erreur statut: {e}", exc_info=True)
# WRITE FINAL pour modifications simples
logger.info("")
logger.info(" Write() modifications simples...")
try:
doc.Write()
logger.info(" Write() réussi")
time.sleep(0.5)
doc.Read()
logger.info(" Read() après Write() OK")
except Exception as e:
logger.error(f" Write() a échoué: {e}", exc_info=True)
raise
# ========================================
# ÉTAPE 6 : REMPLACEMENT COMPLET DES LIGNES
# ========================================
elif modif_lignes:
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 5B] REMPLACEMENT COMPLET DES LIGNES")
logger.info("=" * 80)
# Modifier dates AVANT les lignes
if modif_date:
logger.info(" Modification date devis (avant lignes)...")
try:
nouvelle_date = self.normaliser_date(devis_data_temp["date_devis"])
doc.DO_Date = pywintypes.Time(nouvelle_date)
champs_modifies.append("date_devis")
logger.info(f" Date: {nouvelle_date.strftime('%Y-%m-%d')}")
except Exception as e:
logger.error(f" Erreur: {e}")
if modif_date_livraison:
logger.info(" Modification date livraison (avant lignes)...")
try:
if devis_data_temp["date_livraison"]:
nouvelle_date_livr = self.normaliser_date(devis_data_temp["date_livraison"])
doc.DO_DateLivr = pywintypes.Time(nouvelle_date_livr)
logger.info(f" Date livraison: {nouvelle_date_livr.strftime('%Y-%m-%d')}")
else:
doc.DO_DateLivr = None
logger.info(" Date livraison effacée")
champs_modifies.append("date_livraison")
except Exception as e:
logger.error(f" Erreur: {e}")
nouvelles_lignes = devis_data["lignes"]
nb_nouvelles = len(nouvelles_lignes)
logger.info("")
logger.info(f" Remplacement: {nb_lignes_initial} lignes → {nb_nouvelles} lignes")
# Récupérer factories
try:
factory_lignes = doc.FactoryDocumentLigne
except:
factory_lignes = doc.FactoryDocumentVenteLigne
factory_article = self.cial.FactoryArticle
# ============================================
# SUPPRESSION DE TOUTES LES LIGNES
# ============================================
if nb_lignes_initial > 0:
logger.info("")
logger.info(f" Suppression de {nb_lignes_initial} lignes existantes...")
for idx in range(nb_lignes_initial, 0, -1):
try:
ligne_p = factory_lignes.List(idx)
if ligne_p:
try:
ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3")
except:
ligne = win32com.client.CastTo(ligne_p, "IBODocumentVenteLigne3")
ligne.Read()
ligne.Remove()
logger.debug(f" Ligne {idx} supprimée")
except Exception as e:
logger.warning(f" Ligne {idx} non supprimée: {e}")
logger.info(f" {nb_lignes_initial} lignes supprimées")
# ============================================
# CRÉATION DES NOUVELLES LIGNES
# ============================================
logger.info("")
logger.info(f" Ajout de {nb_nouvelles} nouvelles lignes...")
for idx, ligne_data in enumerate(nouvelles_lignes, 1):
article_code = ligne_data["article_code"]
quantite = float(ligne_data["quantite"])
logger.info("")
logger.info(f" Ligne {idx}/{nb_nouvelles}: {article_code}")
logger.info(f" Quantité: {quantite}")
if ligne_data.get("prix_unitaire_ht"):
logger.info(f" Prix HT: {ligne_data['prix_unitaire_ht']}")
if ligne_data.get("remise_pourcentage"):
logger.info(f" Remise: {ligne_data['remise_pourcentage']}%")
try:
# Charger article
persist_article = factory_article.ReadReference(article_code)
if not persist_article:
raise ValueError(f"Article {article_code} INTROUVABLE")
article_obj = win32com.client.CastTo(persist_article, "IBOArticle3")
article_obj.Read()
logger.info(f" ✓ Article chargé")
# Créer ligne
ligne_persist = factory_lignes.Create()
try:
ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentLigne3")
except:
ligne_obj = win32com.client.CastTo(ligne_persist, "IBODocumentVenteLigne3")
# Associer article
try:
ligne_obj.SetDefaultArticleReference(article_code, quantite)
logger.info(f" ✓ Article associé via SetDefaultArticleReference")
except:
try:
ligne_obj.SetDefaultArticle(article_obj, quantite)
logger.info(f" ✓ Article associé via SetDefaultArticle")
except:
ligne_obj.DL_Design = ligne_data.get("designation", "")
ligne_obj.DL_Qte = quantite
logger.info(f" ✓ Article associé manuellement")
# Prix
if ligne_data.get("prix_unitaire_ht"):
ligne_obj.DL_PrixUnitaire = float(ligne_data["prix_unitaire_ht"])
logger.info(f" ✓ Prix unitaire défini")
# 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
logger.info(f" ✓ Remise définie")
except:
logger.debug(f" Remise non supportée")
# Écrire ligne
ligne_obj.Write()
logger.info(f" Ligne {idx} créée avec succès")
except Exception as e:
logger.error(f" ERREUR ligne {idx}: {e}", exc_info=True)
raise
logger.info("")
logger.info(f" {nb_nouvelles} lignes créées")
# WRITE après lignes
logger.info("")
logger.info(" Write() après remplacement lignes...")
try:
doc.Write()
logger.info(" Write() réussi")
time.sleep(0.5)
doc.Read()
logger.info(" Read() après Write() OK")
except Exception as e:
logger.error(f" Write() a échoué: {e}", exc_info=True)
raise
champs_modifies.append("lignes")
# ========================================
# ÉTAPE 7 : RÉFÉRENCE APRÈS LIGNES
# ========================================
if reference_a_modifier is not None:
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 6] MODIFICATION RÉFÉRENCE (après lignes)")
logger.info("=" * 80)
try:
ancienne_ref = getattr(doc, "DO_Ref", "")
nouvelle_ref = str(reference_a_modifier) if reference_a_modifier else ""
logger.info(f" Actuelle: '{ancienne_ref}'")
logger.info(f" Cible: '{nouvelle_ref}'")
doc.DO_Ref = nouvelle_ref
logger.info(" ✓ doc.DO_Ref affecté")
doc.Write()
logger.info(" ✓ Write()")
time.sleep(0.5)
doc.Read()
logger.info(" ✓ Read()")
champs_modifies.append("reference")
logger.info(f" Référence modifiée: '{ancienne_ref}''{nouvelle_ref}'")
except Exception as e:
logger.error(f" Erreur référence: {e}", exc_info=True)
# ========================================
# ÉTAPE 8 : STATUT EN DERNIER
# ========================================
if statut_a_modifier is not None:
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 7] MODIFICATION STATUT (en dernier)")
logger.info("=" * 80)
try:
statut_actuel = getattr(doc, "DO_Statut", 0)
nouveau_statut = int(statut_a_modifier)
logger.info(f" Actuel: {statut_actuel}")
logger.info(f" Cible: {nouveau_statut}")
if nouveau_statut != statut_actuel and nouveau_statut in [0, 1, 2, 3]:
doc.DO_Statut = nouveau_statut
logger.info(" ✓ doc.DO_Statut affecté")
doc.Write()
logger.info(" ✓ Write()")
time.sleep(0.5)
doc.Read()
logger.info(" ✓ Read()")
champs_modifies.append("statut")
logger.info(f" Statut modifié: {statut_actuel}{nouveau_statut}")
else:
logger.info(f" Pas de modification (identique ou invalide)")
except Exception as e:
logger.error(f" Erreur statut: {e}", exc_info=True)
# ========================================
# ÉTAPE 9 : VALIDATION FINALE
# ========================================
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 8] VALIDATION FINALE")
logger.info("=" * 80)
try:
doc.Write()
logger.info(" Write() final")
except Exception as e:
logger.warning(f" Write() final: {e}")
time.sleep(0.5)
doc.Read()
logger.info(" Read() final")
# Afficher état final
logger.info("")
self._afficher_etat_document(doc, "📸 ÉTAT FINAL")
# ========================================
# ÉTAPE 10 : EXTRACTION RÉSULTAT
# ========================================
logger.info("")
logger.info("=" * 80)
logger.info(" [ÉTAPE 9] EXTRACTION RÉSULTAT")
logger.info("=" * 80)
resultat = self._extraire_infos_devis(doc, numero, champs_modifies)
logger.info(f" Résultat extrait:")
logger.info(f" Numéro: {resultat['numero']}")
logger.info(f" Référence: '{resultat['reference']}'")
logger.info(f" Date devis: {resultat['date_devis']}")
logger.info(f" Date livraison: {resultat['date_livraison']}")
logger.info(f" Statut: {resultat['statut']}")
logger.info(f" Total HT: {resultat['total_ht']}")
logger.info(f" Total TTC: {resultat['total_ttc']}")
logger.info(f" Champs modifiés: {resultat['champs_modifies']}")
logger.info("")
logger.info("=" * 100)
logger.info(f" MODIFICATION DEVIS {numero} TERMINÉE AVEC SUCCÈS ")
logger.info("=" * 100)
return resultat
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 _afficher_etat_document(self, doc, titre: str):
"""Affiche l'état complet d'un document."""
logger.info("-" * 80)
logger.info(titre)
logger.info("-" * 80)
try:
logger.info(f" DO_Piece: {getattr(doc, 'DO_Piece', 'N/A')}")
logger.info(f" DO_Ref: '{getattr(doc, 'DO_Ref', 'N/A')}'")
logger.info(f" DO_Statut: {getattr(doc, 'DO_Statut', 'N/A')}")
date_doc = getattr(doc, 'DO_Date', None)
date_str = date_doc.strftime('%Y-%m-%d') if date_doc else 'None'
logger.info(f" DO_Date: {date_str}")
date_livr = getattr(doc, 'DO_DateLivr', None)
date_livr_str = date_livr.strftime('%Y-%m-%d') if date_livr else 'None'
logger.info(f" DO_DateLivr: {date_livr_str}")
logger.info(f" DO_TotalHT: {getattr(doc, 'DO_TotalHT', 0)}")
logger.info(f" DO_TotalTTC: {getattr(doc, 'DO_TotalTTC', 0)}")
except Exception as e:
logger.error(f" Erreur affichage état: {e}")
logger.info("-" * 80)
def _compter_lignes_document(self, doc) -> int:
"""Compte les lignes d'un document."""
try:
try:
factory_lignes = doc.FactoryDocumentLigne
except:
factory_lignes = doc.FactoryDocumentVenteLigne
count = 0
index = 1
while index <= 100:
try:
ligne_p = factory_lignes.List(index)
if ligne_p is None:
break
count += 1
index += 1
except:
break
return count
except Exception as e:
logger.warning(f" Erreur comptage lignes: {e}")
return 0
def _charger_devis(self, numero: str):
"""Charge un devis depuis Sage."""
logger.info(f" Chargement devis {numero}...")
factory = self.cial.FactoryDocumentVente
# Tentative 1: ReadPiece
logger.info(" Tentative ReadPiece(0, numero)...")
persist = factory.ReadPiece(0, numero)
if not persist:
logger.warning(" ReadPiece a échoué, recherche dans la liste...")
persist = self._rechercher_devis_par_numero(numero, factory)
if not persist:
raise ValueError(f" Devis {numero} INTROUVABLE")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
logger.info(f" Devis {numero} chargé")
return doc
def _rechercher_devis_par_numero(self, numero: str, factory):
"""Recherche un devis par numéro dans la liste."""
logger.info(f" Recherche de {numero} dans la liste...")
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
):
logger.info(f" Trouvé à l'index {index}")
return persist_test
index += 1
except:
index += 1
logger.error(f" Devis {numero} non trouvé dans la liste")
return None
def _verifier_devis_non_transforme(self, numero: str, doc):
"""Vérifie que le devis n'est pas transformé."""
verification = self.verifier_si_deja_transforme_sql(numero, 0)
if verification["deja_transforme"]:
docs_cibles = verification["documents_cibles"]
nums = [d["numero"] for d in docs_cibles]
raise ValueError(
f" Devis {numero} déjà transformé en {len(docs_cibles)} document(s): {', '.join(nums)}"
)
statut_actuel = getattr(doc, "DO_Statut", 0)
if statut_actuel == 5:
raise ValueError(f" Devis {numero} déjà transformé (statut=5)")
def _extraire_infos_devis(self, doc, numero: str, champs_modifies: list) -> Dict:
"""Extrait les informations complètes du devis."""
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
statut = getattr(doc, "DO_Statut", 0)
reference = getattr(doc, "DO_Ref", "")
# Date devis
date_devis = None
try:
date_doc = getattr(doc, "DO_Date", None)
if date_doc:
date_devis = date_doc.strftime("%Y-%m-%d")
except:
pass
# Date livraison
date_livraison = None
try:
date_livr = getattr(doc, "DO_DateLivr", None)
if date_livr:
date_livraison = date_livr.strftime("%Y-%m-%d")
except:
pass
# Client
client_code = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code = getattr(client_obj, "CT_Num", "")
except:
pass
return {
"numero": numero,
"total_ht": total_ht,
"total_ttc": total_ttc,
"reference": reference,
"date_devis": date_devis,
"date_livraison": date_livraison,
"champs_modifies": champs_modifies,
"statut": statut,
"client_code": client_code,
}
def lire_devis(self, numero_devis):
try:
# Lire le devis via SQL
devis = self._lire_document_sql(numero_devis, type_doc=0)
if not devis:
return None
return devis
except Exception as e:
logger.error(f" Erreur SQL lecture devis {numero_devis}: {e}")
return None
def lire_document(self, numero, type_doc):
return self._lire_document_sql(numero, type_doc)
def verifier_si_deja_transforme_sql(self, numero_source, type_source):
"""Version corrigée avec normalisation des types"""
logger.info(
f"[VERIF] Vérification transformations de {numero_source} (type {type_source})"
)
logger.info(
f"[VERIF] Vérification transformations de {numero_source} (type {type_source})"
)
# DEBUG COMPLET
logger.info(f"[DEBUG] Type source brut: {type_source}")
logger.info(
f"[DEBUG] Type source après normalisation: {self._normaliser_type_document(type_source)}"
)
logger.info(
f"[DEBUG] Type source après normalisation SQL: {self._convertir_type_pour_sql(type_source)}"
)
# NORMALISER le type source
type_source = self._convertir_type_pour_sql(type_source)
champ_liaison_mapping = {
0: "DL_PieceDE",
1: "DL_PieceBC",
3: "DL_PieceBL",
}
champ_liaison = champ_liaison_mapping.get(type_source)
if not champ_liaison:
logger.warning(f"[VERIF] Type source {type_source} non géré")
return {"deja_transforme": False, "documents_cibles": []}
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = f"""
SELECT DISTINCT
dc.DO_Piece,
dc.DO_Type,
dc.DO_Statut,
(SELECT COUNT(*) FROM F_DOCLIGNE
WHERE DO_Piece = dc.DO_Piece AND DO_Type = dc.DO_Type) as NbLignes
FROM F_DOCENTETE dc
INNER JOIN F_DOCLIGNE dl ON dc.DO_Piece = dl.DO_Piece AND dc.DO_Type = dl.DO_Type
WHERE dl.{champ_liaison} = ?
ORDER BY dc.DO_Type, dc.DO_Piece
"""
cursor.execute(query, (numero_source,))
resultats = cursor.fetchall()
documents_cibles = []
for row in resultats:
type_brut = int(row.DO_Type)
type_normalise = self._convertir_type_depuis_sql(type_brut)
doc = {
"numero": row.DO_Piece.strip() if row.DO_Piece else "",
"type": type_normalise, # ← TYPE NORMALISÉ
"type_brut": type_brut, # Garder aussi le type original
"type_libelle": self._get_type_libelle(type_brut),
"statut": int(row.DO_Statut) if row.DO_Statut else 0,
"nb_lignes": int(row.NbLignes) if row.NbLignes else 0,
}
documents_cibles.append(doc)
logger.info(
f"[VERIF] Trouvé: {doc['numero']} "
f"(type {type_brut}{type_normalise} - {doc['type_libelle']}) "
f"- {doc['nb_lignes']} lignes"
)
deja_transforme = len(documents_cibles) > 0
if deja_transforme:
logger.info(
f"[VERIF] Document {numero_source} a {len(documents_cibles)} transformation(s)"
)
else:
logger.info(
f"[VERIF] Document {numero_source} pas encore transformé"
)
return {
"deja_transforme": deja_transforme,
"documents_cibles": documents_cibles,
}
except Exception as e:
logger.error(f"[VERIF] Erreur vérification: {e}")
return {"deja_transforme": False, "documents_cibles": []}
def peut_etre_transforme(self, numero_source, type_source, type_cible):
"""Version corrigée avec normalisation"""
# NORMALISER les types
type_source = self._normaliser_type_document(type_source)
type_cible = self._normaliser_type_document(type_cible)
logger.info(
f"[VERIF_TRANSFO] {numero_source} "
f"(type {type_source}) → type {type_cible}"
)
verif = self.verifier_si_deja_transforme_sql(numero_source, type_source)
# Comparer avec le type NORMALISÉ
docs_meme_type = [
d for d in verif["documents_cibles"] if d["type"] == type_cible
]
if docs_meme_type:
nums = [d["numero"] for d in docs_meme_type]
return {
"possible": False,
"raison": f"Document déjà transformé en {self._get_type_libelle(type_cible)}",
"documents_existants": docs_meme_type,
"message_detaille": f"Document(s) existant(s): {', '.join(nums)}",
}
return {
"possible": True,
"raison": "Transformation possible",
"documents_existants": [],
}
def _get_type_libelle(self, type_doc: int) -> str:
"""
Retourne le libellé d'un type de document.
Gère les 2 formats : valeur réelle Sage (0,10,20...) ET valeur affichée (0,1,2...)
"""
# Mapping principal (valeurs Sage officielles)
types_officiels = {
0: "Devis",
10: "Bon de commande",
20: "Préparation",
30: "Bon de livraison",
40: "Bon de retour",
50: "Bon d'avoir",
60: "Facture",
}
# Mapping alternatif (parfois Sage stocke 1 au lieu de 10, 2 au lieu de 20, etc.)
types_alternatifs = {
1: "Bon de commande",
2: "Préparation",
3: "Bon de livraison",
4: "Bon de retour",
5: "Bon d'avoir",
6: "Facture",
}
# Essayer d'abord le mapping officiel
if type_doc in types_officiels:
return types_officiels[type_doc]
# Puis le mapping alternatif
if type_doc in types_alternatifs:
return types_alternatifs[type_doc]
return f"Type {type_doc}"
def _normaliser_type_document(self, type_doc: int) -> int:
"""
Normalise le type de document vers la valeur officielle Sage.
Convertit 1→10, 2→20, etc. si nécessaire
"""
# Si c'est déjà un type officiel, le retourner tel quel
logger.info(f"[INFO] TYPE RECU{type_doc}")
if type_doc in [0, 10, 20, 30, 40, 50, 60]:
return type_doc
# Sinon, essayer de convertir
mapping_normalisation = {
1: 10, # Commande
2: 20, # Préparation
3: 30, # BL
4: 40, # Retour
5: 50, # Avoir
6: 60, # Facture
}
return mapping_normalisation.get(type_doc, type_doc)
def transformer_document(
self,
numero_source,
type_source,
type_cible,
ignorer_controle_stock=False,
conserver_document_source=True,
verifier_doublons=True,
):
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
type_source = int(type_source)
type_cible = int(type_cible)
logger.info(
f"[TRANSFORM] Transformation: {numero_source} ({type_source}) → type {type_cible}"
)
# ========================================
# VALIDATION DES TYPES
# ========================================
transformations_valides = {
(0, 10): ("Vente", "CreateProcess_Commander"),
(0, 60): ("Vente", "CreateProcess_Facturer"),
(10, 30): ("Vente", "CreateProcess_Livrer"),
(10, 60): ("Vente", "CreateProcess_Facturer"),
(30, 60): ("Vente", "CreateProcess_Facturer"),
}
if (type_source, type_cible) not in transformations_valides:
raise ValueError(
f"Transformation non autorisée: "
f"{self._get_type_libelle(type_source)}{self._get_type_libelle(type_cible)}"
)
module, methode = transformations_valides[(type_source, type_cible)]
logger.info(f"[TRANSFORM] Méthode: Transformation.{module}.{methode}()")
# ========================================
# VÉRIFICATION OPTIONNELLE DES DOUBLONS
# ========================================
if verifier_doublons:
logger.info("[TRANSFORM] Vérification des doublons...")
verif = self.peut_etre_transforme(numero_source, type_source, type_cible)
if not verif["possible"]:
docs = [d["numero"] for d in verif.get("documents_existants", [])]
raise ValueError(
f"{verif['raison']}. Document(s) existant(s): {', '.join(docs)}"
)
logger.info("[TRANSFORM] Aucun doublon détecté")
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
# ========================================
# ÉTAPE 1 : LIRE LE DOCUMENT SOURCE
# ========================================
logger.info(f"[TRANSFORM] Lecture de {numero_source}...")
if not factory.ExistPiece(type_source, numero_source):
raise ValueError(f"Document {numero_source} introuvable")
persist_source = factory.ReadPiece(type_source, numero_source)
if not persist_source:
raise ValueError(f"Impossible de lire {numero_source}")
doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3")
doc_source.Read()
# Informations du document source
statut_source = getattr(doc_source, "DO_Statut", 0)
nb_lignes_source = 0
try:
factory_lignes = getattr(doc_source, "FactoryDocumentLigne", None)
if factory_lignes:
lignes_list = factory_lignes.List
nb_lignes_source = lignes_list.Count if lignes_list else 0
except:
pass
logger.info(
f"[TRANSFORM] Source: statut={statut_source}, {nb_lignes_source} ligne(s)"
)
if nb_lignes_source == 0:
raise ValueError(f"Document {numero_source} vide (0 lignes)")
# ========================================
# ÉTAPE 2 : CRÉER LE TRANSFORMER
# ========================================
logger.info("[TRANSFORM] 🔧 Création du transformer...")
transformation = getattr(self.cial, "Transformation", None)
if not transformation:
raise RuntimeError("API Transformation non disponible")
module_obj = getattr(transformation, module, None)
if not module_obj:
raise RuntimeError(f"Module {module} non disponible")
methode_func = getattr(module_obj, methode, None)
if not methode_func:
raise RuntimeError(f"Méthode {methode} non disponible")
transformer = methode_func()
if not transformer:
raise RuntimeError("Échec création transformer")
logger.info("[TRANSFORM] Transformer créé")
# ========================================
# ÉTAPE 3 : CONFIGURATION
# ========================================
logger.info("[TRANSFORM] Configuration...")
# Tenter de définir ConserveDocuments
if hasattr(transformer, "ConserveDocuments"):
try:
transformer.ConserveDocuments = conserver_document_source
logger.info(
f"[TRANSFORM] ConserveDocuments = {conserver_document_source}"
)
except Exception as e:
logger.warning(
f"[TRANSFORM] ConserveDocuments non modifiable: {e}"
)
# ========================================
# ÉTAPE 4 : AJOUTER LE DOCUMENT
# ========================================
logger.info("[TRANSFORM] Ajout du document...")
try:
transformer.AddDocument(doc_source)
logger.info("[TRANSFORM] Document ajouté")
except Exception as e:
raise RuntimeError(f"Impossible d'ajouter le document: {e}")
# ========================================
# ÉTAPE 5 : VÉRIFIER CANPROCESS
# ========================================
try:
can_process = getattr(transformer, "CanProcess", False)
logger.info(f"[TRANSFORM] CanProcess: {can_process}")
except:
can_process = True
if not can_process:
erreurs = self.lire_erreurs_sage(transformer, "Transformer")
if erreurs:
msgs = [f"{e['field']}: {e['description']}" for e in erreurs]
raise RuntimeError(
f"Transformation impossible: {' | '.join(msgs)}"
)
raise RuntimeError("Transformation impossible (CanProcess=False)")
# ========================================
# ÉTAPE 6 : TRANSACTION (optionnelle)
# ========================================
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug("[TRANSFORM] Transaction démarrée")
except:
pass
try:
# ========================================
# ÉTAPE 7 : PROCESS (TRANSFORMATION)
# ========================================
logger.info("[TRANSFORM] Process()...")
try:
transformer.Process()
logger.info("[TRANSFORM] Process() réussi")
except Exception as e:
logger.error(f"[TRANSFORM] Erreur Process(): {e}")
erreurs = self.lire_erreurs_sage(transformer, "Transformer")
if erreurs:
msgs = [
f"{e['field']}: {e['description']}" for e in erreurs
]
raise RuntimeError(f"Échec: {' | '.join(msgs)}")
raise RuntimeError(f"Échec transformation: {e}")
# ========================================
# ÉTAPE 8 : RÉCUPÉRER LES RÉSULTATS
# ========================================
logger.info("[TRANSFORM] Récupération des résultats...")
list_results = getattr(transformer, "ListDocumentsResult", None)
if not list_results:
raise RuntimeError("ListDocumentsResult non disponible")
documents_crees = []
index = 1
while index <= 100:
try:
doc_result = list_results.Item(index)
if doc_result is None:
break
doc_result = win32com.client.CastTo(
doc_result, "IBODocumentVente3"
)
doc_result.Read()
numero_cible = getattr(doc_result, "DO_Piece", "").strip()
total_ht = float(getattr(doc_result, "DO_TotalHT", 0.0))
total_ttc = float(getattr(doc_result, "DO_TotalTTC", 0.0))
# Compter les lignes via COM
nb_lignes = 0
try:
factory_lignes_result = getattr(
doc_result, "FactoryDocumentLigne", None
)
if factory_lignes_result:
lignes_list = factory_lignes_result.List
nb_lignes = lignes_list.Count if lignes_list else 0
except:
pass
documents_crees.append(
{
"numero": numero_cible,
"total_ht": total_ht,
"total_ttc": total_ttc,
"nb_lignes": nb_lignes,
}
)
logger.info(
f"[TRANSFORM] Document créé: {numero_cible} "
f"({nb_lignes} lignes, {total_ht}€ HT)"
)
index += 1
except Exception as e:
logger.debug(f"Fin de liste à index {index}")
break
if not documents_crees:
raise RuntimeError("Aucun document créé après Process()")
# ========================================
# ÉTAPE 9 : COMMIT
# ========================================
if transaction_active:
try:
self.cial.CptaApplication.CommitTrans()
logger.debug("[TRANSFORM] Transaction committée")
except:
pass
# Pause de sécurité
time.sleep(1.5)
# ========================================
# RÉSULTAT
# ========================================
doc_principal = documents_crees[0]
logger.info(
f"[TRANSFORM] SUCCÈS: {numero_source}{doc_principal['numero']}"
)
logger.info(
f"[TRANSFORM] • {doc_principal['nb_lignes']} ligne(s)"
)
logger.info(
f"[TRANSFORM] • {doc_principal['total_ht']}€ HT / "
f"{doc_principal['total_ttc']}€ TTC"
)
return {
"success": True,
"document_source": numero_source,
"document_cible": doc_principal["numero"],
"type_source": type_source,
"type_cible": type_cible,
"nb_documents_crees": len(documents_crees),
"documents": documents_crees,
"nb_lignes": doc_principal["nb_lignes"],
"total_ht": doc_principal["total_ht"],
"total_ttc": doc_principal["total_ttc"],
"methode_transformation": f"{module}.{methode}",
}
except Exception as e:
# Rollback en cas d'erreur
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
logger.error("[TRANSFORM] Transaction annulée (rollback)")
except:
pass
raise
except ValueError as e:
# Erreur métier (validation, doublon, etc.)
logger.error(f"[TRANSFORM] Erreur métier: {e}")
raise
except RuntimeError as e:
# Erreur technique Sage
logger.error(f"[TRANSFORM] Erreur technique: {e}")
raise
except Exception as e:
# Erreur inattendue
logger.error(f"[TRANSFORM] Erreur inattendue: {e}", exc_info=True)
raise RuntimeError(f"Échec transformation: {str(e)}")
def lire_erreurs_sage(self, obj, nom_obj=""):
"""
Lit toutes les erreurs d'un objet Sage COM.
Utilisé pour diagnostiquer les échecs de Process().
"""
erreurs = []
try:
if not hasattr(obj, "Errors") or obj.Errors is None:
return erreurs
nb_erreurs = 0
try:
nb_erreurs = obj.Errors.Count
except:
return erreurs
if nb_erreurs == 0:
return erreurs
for i in range(1, nb_erreurs + 1):
try:
err = None
# Plusieurs façons d'accéder aux erreurs selon la version Sage
try:
err = obj.Errors.Item(i)
except:
try:
err = obj.Errors(i)
except:
try:
err = obj.Errors.Item(i - 1)
except:
pass
if err is not None:
description = ""
field = ""
number = ""
# Description
for attr in ["Description", "Descr", "Message", "Text"]:
try:
val = getattr(err, attr, None)
if val:
description = str(val)
break
except:
pass
# Champ concerné
for attr in ["Field", "FieldName", "Champ", "Property"]:
try:
val = getattr(err, attr, None)
if val:
field = str(val)
break
except:
pass
# Numéro d'erreur
for attr in ["Number", "Code", "ErrorCode", "Numero"]:
try:
val = getattr(err, attr, None)
if val is not None:
number = str(val)
break
except:
pass
if description or field or number:
erreurs.append(
{
"source": nom_obj,
"index": i,
"description": description or "Erreur inconnue",
"field": field or "?",
"number": number or "?",
}
)
except Exception as e:
logger.debug(f"Erreur lecture erreur {i}: {e}")
continue
except Exception as e:
logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}")
return erreurs
def _find_document_in_list(self, numero, type_doc):
"""Cherche un document dans List() si ReadPiece échoue"""
try:
factory = self.cial.FactoryDocumentVente
index = 1
while index < 10000:
try:
persist = factory.List(index)
if persist is None:
break
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
if (
getattr(doc, "DO_Type", -1) == type_doc
and getattr(doc, "DO_Piece", "") == numero
):
logger.info(f"[TRANSFORM] Document trouve a l'index {index}")
return persist
index += 1
except:
index += 1
continue
return None
except Exception as e:
logger.error(f"[TRANSFORM] Erreur recherche document: {e}")
return None
def mettre_a_jour_champ_libre(self, doc_id, type_doc, nom_champ, valeur):
"""Mise à jour champ libre pour Universign ID"""
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentVente
persist = factory.ReadPiece(type_doc, doc_id)
if persist:
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
try:
setattr(doc, f"DO_{nom_champ}", valeur)
doc.Write()
logger.debug(f"Champ libre {nom_champ} = {valeur} sur {doc_id}")
return True
except Exception as e:
logger.warning(f"Impossible de mettre à jour {nom_champ}: {e}")
except Exception as e:
logger.error(f"Erreur MAJ champ libre: {e}")
return False
def _lire_client_obj(self, code_client):
"""Retourne l'objet client Sage brut (pour remises)"""
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory = self.cial.CptaApplication.FactoryClient
persist = factory.ReadNumero(code_client)
if persist:
return self._cast_client(persist)
except:
pass
return None
def lire_contact_principal_client(self, code_client):
if not self.cial:
return None
try:
with self._com_context(), self._lock_com:
factory_client = self.cial.CptaApplication.FactoryClient
persist_client = factory_client.ReadNumero(code_client)
if not persist_client:
return None
client = self._cast_client(persist_client)
if not client:
return None
# Récupérer infos contact principal
contact_info = {
"client_code": code_client,
"client_intitule": getattr(client, "CT_Intitule", ""),
"email": None,
"nom": None,
"telephone": None,
}
# Email principal depuis Telecom
try:
telecom = getattr(client, "Telecom", None)
if telecom:
contact_info["email"] = getattr(telecom, "EMail", "")
contact_info["telephone"] = getattr(telecom, "Telephone", "")
except:
pass
# Nom du contact
try:
contact_info["nom"] = (
getattr(client, "CT_Contact", "")
or contact_info["client_intitule"]
)
except:
contact_info["nom"] = contact_info["client_intitule"]
return contact_info
except Exception as e:
logger.error(f"Erreur lecture contact client {code_client}: {e}")
return None
def mettre_a_jour_derniere_relance(self, doc_id, type_doc):
date_relance = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
return self.mettre_a_jour_champ_libre(
doc_id, type_doc, "DerniereRelance", date_relance
)
def lister_tous_prospects(self, filtre=""):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = """
SELECT
CT_Num, CT_Intitule, CT_Adresse, CT_Ville,
CT_CodePostal, CT_Telephone, CT_EMail
FROM F_COMPTET
WHERE CT_Type = 0 AND CT_Prospect = 1
"""
params = []
if filtre:
query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)"
params.extend([f"%{filtre}%", f"%{filtre}%"])
query += " ORDER BY CT_Intitule"
cursor.execute(query, params)
rows = cursor.fetchall()
prospects = []
for row in rows:
prospects.append(
{
"numero": self._safe_strip(row.CT_Num),
"intitule": self._safe_strip(row.CT_Intitule),
"adresse": self._safe_strip(row.CT_Adresse),
"ville": self._safe_strip(row.CT_Ville),
"code_postal": self._safe_strip(row.CT_CodePostal),
"telephone": self._safe_strip(row.CT_Telephone),
"email": self._safe_strip(row.CT_EMail),
"type": 0,
"est_prospect": True,
}
)
logger.info(f" SQL: {len(prospects)} prospects")
return prospects
except Exception as e:
logger.error(f" Erreur SQL prospects: {e}")
return []
def lire_prospect(self, code_prospect):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays,
CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie,
CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect,
CT_Contact, CT_FormeJuridique, CT_Secteur
FROM F_COMPTET
WHERE CT_Num = ? AND CT_Type = 0 AND CT_Prospect = 1
""",
(code_prospect.upper(),),
)
row = cursor.fetchone()
if not row:
return None
return {
"numero": self._safe_strip(row.CT_Num),
"intitule": self._safe_strip(row.CT_Intitule),
"type": 0,
"qualite": self._safe_strip(row.CT_Qualite),
"est_prospect": True,
"adresse": self._safe_strip(row.CT_Adresse),
"complement": self._safe_strip(row.CT_Complement),
"ville": self._safe_strip(row.CT_Ville),
"code_postal": self._safe_strip(row.CT_CodePostal),
"pays": self._safe_strip(row.CT_Pays),
"telephone": self._safe_strip(row.CT_Telephone),
"portable": self._safe_strip(row.CT_Portable),
"email": self._safe_strip(row.CT_EMail),
"telecopie": self._safe_strip(row.CT_Telecopie),
"siret": self._safe_strip(row.CT_Siret),
"tva_intra": self._safe_strip(row.CT_Identifiant),
"est_actif": (row.CT_Sommeil == 0),
"contact": self._safe_strip(row.CT_Contact),
"forme_juridique": self._safe_strip(row.CT_FormeJuridique),
"secteur": self._safe_strip(row.CT_Secteur),
}
except Exception as e:
logger.error(f" Erreur SQL prospect {code_prospect}: {e}")
return None
def lister_avoirs(self, limit=100, statut=None):
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = f"""
SELECT TOP ({limit})
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.CT_Num, c.CT_Intitule
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num
WHERE d.DO_Type = 50
"""
params = []
if statut is not None:
query += " AND d.DO_Statut = ?"
params.append(statut)
query += " ORDER BY d.DO_Date DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
avoirs = []
for row in rows:
avoirs.append(
{
"numero": self._safe_strip(row.DO_Piece),
"reference": self._safe_strip(row.DO_Ref),
"date": str(row.DO_Date) if row.DO_Date else "",
"client_code": self._safe_strip(row.CT_Num),
"client_intitule": self._safe_strip(row.CT_Intitule),
"total_ht": (
float(row.DO_TotalHT) if row.DO_TotalHT else 0.0
),
"total_ttc": (
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
),
"statut": row.DO_Statut if row.DO_Statut is not None else 0,
}
)
return avoirs
except Exception as e:
logger.error(f" Erreur SQL avoirs: {e}")
return []
def lire_avoir(self, numero):
return self._lire_document_sql(numero, type_doc=50)
def lister_livraisons(self, limit=100, statut=None):
""" Liste les livraisons via SQL (méthode legacy)"""
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
query = f"""
SELECT TOP ({limit})
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
d.DO_Statut, d.CT_Num, c.CT_Intitule
FROM F_DOCENTETE d
LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num
WHERE d.DO_Type = 30
"""
params = []
if statut is not None:
query += " AND d.DO_Statut = ?"
params.append(statut)
query += " ORDER BY d.DO_Date DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
livraisons = []
for row in rows:
livraisons.append(
{
"numero": self._safe_strip(row.DO_Piece),
"reference": self._safe_strip(row.DO_Ref),
"date": str(row.DO_Date) if row.DO_Date else "",
"client_code": self._safe_strip(row.CT_Num),
"client_intitule": self._safe_strip(row.CT_Intitule),
"total_ht": (
float(row.DO_TotalHT) if row.DO_TotalHT else 0.0
),
"total_ttc": (
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
),
"statut": row.DO_Statut if row.DO_Statut is not None else 0,
}
)
return livraisons
except Exception as e:
logger.error(f" Erreur SQL livraisons: {e}")
return []
def lire_livraison(self, numero):
""" Lit UNE livraison via SQL (avec lignes)"""
return self._lire_document_sql(numero, type_doc=30)
def creer_client(self, client_data: Dict) -> Dict:
"""
Creation d'un tiers Sage 100c (Client/Fournisseur/Salarie/Autre)
Version sans catégories pour test initial
"""
if not self.cial:
raise RuntimeError("Connexion Sage non etablie")
try:
with self._com_context(), self._lock_com:
# ============================================================
# CONSTANTES
# ============================================================
LENGTHS = {
"CT_Num": 17, "CT_Intitule": 69, "CT_Qualite": 17,
"CT_Classement": 17, "CT_Raccourci": 7, "CT_Contact": 35,
"CT_Adresse": 35, "CT_Complement": 35, "CT_CodePostal": 9,
"CT_Ville": 35, "CT_CodeRegion": 25, "CT_Pays": 35,
"CT_Telephone": 21, "CT_Telecopie": 21, "CT_EMail": 69,
"CT_Site": 69, "CT_Facebook": 35, "CT_LinkedIn": 35,
"CT_Siret": 15, "CT_Identifiant": 25, "CT_Ape": 7,
"CT_NumPayeur": 17, "CT_NumCentrale": 17, "CT_Commentaire": 35,
"CT_Statistique": 21, "CT_Coface": 25, "CT_SvFormeJuri": 33,
"CT_SvEffectif": 11, "CT_SvRegul": 3, "CT_SvCotation": 5,
"CT_SvObjetMaj": 61, "CT_EdiCode": 23, "CT_EdiCodeSage": 9,
"CT_RepresentInt": 35, "CT_RepresentNIF": 25, "CT_LangueISO2": 3,
"CT_FEAutreIdentifVal": 81, "CG_NumPrinc": 13, "CA_Num": 13, "DN_Id": 37,
}
logger.info("=" * 60)
logger.info("[+] CREATION TIERS SAGE - DEBUT")
logger.info("=" * 60)
# ============================================================
# FONCTIONS UTILITAIRES
# ============================================================
def clean_str(value, max_len: int) -> str:
if value is None or str(value).lower() in ('none', 'null', ''):
return ""
return str(value)[:max_len].strip()
def safe_int(value, default=None):
if value is None:
return default
try:
return int(value)
except (ValueError, TypeError):
return default
def safe_float(value, default=None):
if value is None:
return default
try:
return float(value)
except (ValueError, TypeError):
return default
def safe_bool_to_int(value, default=0):
if value is None:
return default
return 1 if value else 0
# ============================================================
# VALIDATION
# ============================================================
if not client_data.get("intitule"):
raise ValueError("Le champ 'intitule' est obligatoire")
if not client_data.get("numero"):
raise ValueError("Le champ 'numero' est obligatoire")
intitule = clean_str(client_data["intitule"], LENGTHS["CT_Intitule"])
numero = clean_str(client_data["numero"], LENGTHS["CT_Num"]).upper()
qualite = clean_str(client_data.get("qualite", "CLI"), LENGTHS["CT_Qualite"])
type_tiers = safe_int(client_data.get("type_tiers"), 0)
logger.info(f" Numero: {numero}")
logger.info(f" Intitule: {intitule}")
logger.info(f" Type tiers: {type_tiers}")
logger.info(f" Qualite: {qualite}")
# ============================================================
# COMPTE GENERAL SELON TYPE
# ============================================================
COMPTES_DEFAUT_TYPE = {
0: "4110000", # Client
1: "4010000", # Fournisseur
2: "421", # Salarie
3: "471", # Autre
}
compte_demande = client_data.get("compte_general") or client_data.get("compte_collectif")
compte = clean_str(
compte_demande or COMPTES_DEFAUT_TYPE.get(type_tiers, "4110000"),
LENGTHS["CG_NumPrinc"]
)
# ============================================================
# SELECTION DE LA FACTORY SELON TYPE_TIERS
# ============================================================
factory_map = {
0: ("FactoryClient", "IBOClient3", "CLIENT"),
1: ("FactoryFourniss", "IBOFournisseur3", "FOURNISSEUR"),
2: ("FactorySalarie", "IBOSalarie3", "SALARIE"),
3: ("FactoryAutre", "IBOAutre3", "AUTRE"),
}
if type_tiers not in factory_map:
raise ValueError(f"Type de tiers invalide: {type_tiers}")
factory_name, interface_name, type_label = factory_map[type_tiers]
logger.info(f"[*] Utilisation de {factory_name} pour type {type_label}")
try:
factory = getattr(self.cial.CptaApplication, factory_name)
except AttributeError:
raise RuntimeError(f"Factory {factory_name} non disponible")
persist = factory.Create()
client = win32com.client.CastTo(persist, interface_name)
client.SetDefault()
logger.info(f"[OK] Objet {type_label} cree")
# ============================================================
# DEBUG : LISTER LES PROPRIETES DISPONIBLES
# ============================================================
logger.info("[?] Propriétés COM disponibles:")
try:
# Lister toutes les propriétés de l'objet COM
if hasattr(client, '_prop_map_get_'):
props = list(client._prop_map_get_.keys())
logger.info(f" Propriétés GET: {', '.join(sorted(props)[:20])}...")
if hasattr(client, '_prop_map_put_'):
props = list(client._prop_map_put_.keys())
logger.info(f" Propriétés PUT: {', '.join(sorted(props)[:20])}...")
except Exception as e:
logger.warning(f" Impossible de lister: {e}")
# ============================================================
# CHAMPS OBLIGATOIRES
# ============================================================
logger.info("[*] Configuration champs obligatoires...")
client.CT_Intitule = intitule
logger.info(f" CT_Intitule = {intitule}")
client.CT_Num = numero
logger.info(f" CT_Num = {numero}")
if qualite:
client.CT_Qualite = qualite
logger.info(f" CT_Qualite = {qualite}")
# ============================================================
# COMPTE GENERAL
# ============================================================
logger.info("[*] Configuration compte general...")
compte_trouve = False
factory_compte = self.cial.CptaApplication.FactoryCompteG
comptes_alternatifs = {
0: ["4110000", "411000", "41100000", "411"],
1: ["4010000", "401000", "40100000", "401"],
2: ["421", "4210000", "421000"],
3: ["471", "4710000", "471000"],
}
if compte:
try:
persist_compte = factory_compte.ReadNumero(compte)
if persist_compte:
compte_obj = win32com.client.CastTo(persist_compte, "IBOCompteG3")
compte_obj.Read()
client.CompteGPrinc = compte_obj
compte_trouve = True
logger.info(f" CompteGPrinc: '{compte}' [OK]")
except Exception as e:
logger.warning(f" CompteGPrinc '{compte}' non trouve: {e}")
if not compte_trouve:
comptes_a_essayer = comptes_alternatifs.get(type_tiers, comptes_alternatifs[0])
for alt in comptes_a_essayer:
try:
persist_compte = factory_compte.ReadNumero(alt)
if persist_compte:
compte_obj = win32com.client.CastTo(persist_compte, "IBOCompteG3")
compte_obj.Read()
client.CompteGPrinc = compte_obj
compte_trouve = True
compte = alt
logger.info(f" CompteGPrinc: '{alt}' [OK]")
break
except:
continue
if not compte_trouve:
raise ValueError(f"Aucun compte general trouve pour type {type_label}")
# ============================================================
# CATEGORIES - OPTIONNELLES POUR LE MOMENT
# ============================================================
logger.info("[*] Configuration categories (optionnel)...")
cat_tarif = safe_int(client_data.get("categorie_tarifaire") or client_data.get("cat_tarif"), 1)
cat_compta = safe_int(client_data.get("categorie_comptable") or client_data.get("cat_compta"), 1)
# Tenter via FactoryCatTarif
logger.info(f" Tentative catégorie tarifaire: {cat_tarif}")
try:
factory_cat_tarif = self.cial.CptaApplication.FactoryCatTarif
persist_cat = factory_cat_tarif.ReadIntitule(str(cat_tarif))
if persist_cat:
cat_obj = win32com.client.CastTo(persist_cat, "IBOCatTarif3")
cat_obj.Read()
client.CatTarif = cat_obj
logger.info(f" CatTarif = {cat_tarif} [OK via Factory]")
except Exception as e:
logger.warning(f" CatTarif non définie: {e}")
# Tenter via FactoryCatCompta
logger.info(f" Tentative catégorie comptable: {cat_compta}")
try:
factory_cat_compta = self.cial.CptaApplication.FactoryCatCompta
persist_cat = factory_cat_compta.ReadIntitule(str(cat_compta))
if persist_cat:
cat_obj = win32com.client.CastTo(persist_cat, "IBOCatCompta3")
cat_obj.Read()
client.CatCompta = cat_obj
logger.info(f" CatCompta = {cat_compta} [OK via Factory]")
except Exception as e:
logger.warning(f" CatCompta non définie: {e}")
# Autres catégories optionnelles
categories_opt = {
"Period": safe_int(client_data.get("periode_reglement") or client_data.get("period"), 1),
"Expedition": safe_int(client_data.get("mode_expedition") or client_data.get("expedition"), 1),
"Condition": safe_int(client_data.get("condition_livraison") or client_data.get("condition"), 1),
"Risque": safe_int(client_data.get("niveau_risque") or client_data.get("risque"), 1),
}
for attr, val in categories_opt.items():
if val is not None:
try:
setattr(client, attr, val)
logger.debug(f" {attr} = {val}")
except Exception as e:
logger.debug(f" {attr} non défini: {e}")
# ============================================================
# IDENTIFICATION
# ============================================================
logger.info("[*] Identification...")
id_fields = {
"CT_Classement": ("classement", LENGTHS["CT_Classement"]),
"CT_Raccourci": ("raccourci", LENGTHS["CT_Raccourci"]),
"CT_Siret": ("siret", LENGTHS["CT_Siret"]),
"CT_Identifiant": ("tva_intra", LENGTHS["CT_Identifiant"]),
"CT_Ape": ("code_naf", LENGTHS["CT_Ape"]),
}
for attr_sage, (key_data, max_len) in id_fields.items():
val = client_data.get(key_data)
if val:
try:
setattr(client, attr_sage, clean_str(val, max_len))
except:
pass
if client_data.get("banque_num") is not None:
try:
client.BT_Num = safe_int(client_data["banque_num"])
except:
pass
if client_data.get("devise") is not None:
try:
client.N_Devise = safe_int(client_data["devise"], 0)
except:
pass
if client_data.get("type_nif") is not None:
try:
client.CT_TypeNIF = safe_int(client_data["type_nif"])
except:
pass
# ============================================================
# ADRESSE
# ============================================================
logger.info("[*] Adresse...")
adresse_fields = {
"CT_Contact": ("contact", LENGTHS["CT_Contact"]),
"CT_Adresse": ("adresse", LENGTHS["CT_Adresse"]),
"CT_Complement": ("complement", LENGTHS["CT_Complement"]),
"CT_CodePostal": ("code_postal", LENGTHS["CT_CodePostal"]),
"CT_Ville": ("ville", LENGTHS["CT_Ville"]),
"CT_CodeRegion": ("region", LENGTHS["CT_CodeRegion"]),
"CT_Pays": ("pays", LENGTHS["CT_Pays"]),
}
for attr_sage, (key_data, max_len) in adresse_fields.items():
val = client_data.get(key_data)
if val and str(val).lower() not in ('none', 'null'):
try:
setattr(client, attr_sage, clean_str(val, max_len))
except:
pass
# ============================================================
# COMMUNICATION
# ============================================================
logger.info("[*] Communication...")
telecom_fields = {
"CT_Telephone": ("telephone", LENGTHS["CT_Telephone"]),
"CT_Telecopie": ("telecopie", LENGTHS["CT_Telecopie"]),
"CT_EMail": ("email", LENGTHS["CT_EMail"]),
"CT_Site": ("site_web", LENGTHS["CT_Site"]),
"CT_Facebook": ("facebook", LENGTHS["CT_Facebook"]),
"CT_LinkedIn": ("linkedin", LENGTHS["CT_LinkedIn"]),
}
for attr_sage, (key_data, max_len) in telecom_fields.items():
val = client_data.get(key_data)
if val and str(val).lower() not in ('none', 'null'):
try:
setattr(client, attr_sage, clean_str(val, max_len))
except:
pass
# ============================================================
# TAUX
# ============================================================
logger.info("[*] Taux...")
for i in range(1, 5):
val = client_data.get(f"taux{i:02d}")
if val is not None:
try:
setattr(client, f"CT_Taux{i:02d}", safe_float(val))
except:
pass
# ============================================================
# COMMERCIAL
# ============================================================
logger.info("[*] Commercial...")
if client_data.get("encours_autorise") is not None:
try:
client.CT_Encours = safe_float(client_data["encours_autorise"])
except:
pass
if client_data.get("assurance_credit") is not None:
try:
client.CT_Assurance = safe_float(client_data["assurance_credit"])
except:
pass
if client_data.get("num_payeur"):
try:
client.CT_NumPayeur = clean_str(client_data["num_payeur"], LENGTHS["CT_NumPayeur"])
except:
pass
if client_data.get("langue") is not None:
try:
client.CT_Langue = safe_int(client_data["langue"])
except:
pass
if client_data.get("langue_iso2"):
try:
client.CT_LangueISO2 = clean_str(client_data["langue_iso2"], LENGTHS["CT_LangueISO2"])
except:
pass
commercial = client_data.get("commercial_code") or client_data.get("collaborateur")
if commercial is not None:
try:
client.CO_No = safe_int(commercial)
except:
pass
# ============================================================
# FACTURATION
# ============================================================
logger.info("[*] Facturation...")
bool_fields = {
"CT_Lettrage": "lettrage_auto",
"CT_Sommeil": "est_en_sommeil",
"CT_Prospect": "est_prospect",
}
for attr, key in bool_fields.items():
val = client_data.get(key)
if val is not None:
try:
setattr(client, attr, safe_bool_to_int(val))
except:
pass
elif key == "est_en_sommeil" and "est_actif" in client_data:
try:
setattr(client, attr, safe_bool_to_int(not client_data["est_actif"]))
except:
pass
int_fields = {
"CT_Facture": "type_facture",
"CT_BLFact": "bl_en_facture",
"CT_Saut": "saut_page",
"CT_ValidEch": "validation_echeance",
"CT_ControlEnc": "controle_encours",
"CT_NotRappel": "exclure_relance",
"CT_NotPenal": "exclure_penalites",
"CT_BonAPayer": "bon_a_payer",
}
for attr, key in int_fields.items():
val = client_data.get(key)
if val is not None:
try:
setattr(client, attr, safe_int(val))
except:
pass
# ============================================================
# LOGISTIQUE (je garde compact pour la longueur)
# ============================================================
logger.info("[*] Logistique...")
logistique = {
"CT_PrioriteLivr": "priorite_livraison",
"CT_LivrPartielle": "livraison_partielle",
"CT_DelaiTransport": "delai_transport",
"CT_DelaiAppro": "delai_appro",
}
for attr, key in logistique.items():
val = client_data.get(key)
if val is not None:
try:
setattr(client, attr, safe_int(val))
except:
pass
# Jours commande/livraison
if client_data.get("jours_commande"):
jours_map = [
("CT_OrderDay01", "lundi"), ("CT_OrderDay02", "mardi"),
("CT_OrderDay03", "mercredi"), ("CT_OrderDay04", "jeudi"),
("CT_OrderDay05", "vendredi"), ("CT_OrderDay06", "samedi"),
("CT_OrderDay07", "dimanche"),
]
for attr_sage, jour_key in jours_map:
val = client_data["jours_commande"].get(jour_key)
if val is not None:
try:
setattr(client, attr_sage, safe_int(val))
except:
pass
if client_data.get("jours_livraison"):
jours_map = [
("CT_DeliveryDay01", "lundi"), ("CT_DeliveryDay02", "mardi"),
("CT_DeliveryDay03", "mercredi"), ("CT_DeliveryDay04", "jeudi"),
("CT_DeliveryDay05", "vendredi"), ("CT_DeliveryDay06", "samedi"),
("CT_DeliveryDay07", "dimanche"),
]
for attr_sage, jour_key in jours_map:
val = client_data["jours_livraison"].get(jour_key)
if val is not None:
try:
setattr(client, attr_sage, safe_int(val))
except:
pass
for date_attr, date_key in [
("CT_DateFermeDebut", "date_fermeture_debut"),
("CT_DateFermeFin", "date_fermeture_fin")
]:
if client_data.get(date_key):
try:
setattr(client, date_attr, client_data[date_key])
except:
pass
# ============================================================
# STATISTIQUES
# ============================================================
logger.info("[*] Statistiques...")
stat01 = client_data.get("statistique01") or client_data.get("secteur")
if stat01:
try:
client.CT_Statistique01 = clean_str(stat01, LENGTHS["CT_Statistique"])
except:
pass
for i in range(2, 11):
val = client_data.get(f"statistique{i:02d}")
if val:
try:
setattr(client, f"CT_Statistique{i:02d}", clean_str(val, LENGTHS["CT_Statistique"]))
except:
pass
# ============================================================
# COMMENTAIRE
# ============================================================
if client_data.get("commentaire"):
try:
client.CT_Commentaire = clean_str(client_data["commentaire"], LENGTHS["CT_Commentaire"])
except:
pass
# ============================================================
# ANALYTIQUE
# ============================================================
logger.info("[*] Analytique...")
if client_data.get("section_analytique"):
try:
client.CA_Num = clean_str(client_data["section_analytique"], LENGTHS["CA_Num"])
except:
pass
if client_data.get("section_analytique_ifrs"):
try:
client.CA_NumIFRS = clean_str(client_data["section_analytique_ifrs"], LENGTHS["CA_Num"])
except:
pass
if client_data.get("plan_analytique") is not None:
try:
client.N_Analytique = safe_int(client_data["plan_analytique"])
except:
pass
if client_data.get("plan_analytique_ifrs") is not None:
try:
client.N_AnalytiqueIFRS = safe_int(client_data["plan_analytique_ifrs"])
except:
pass
# ============================================================
# ORGANISATION
# ============================================================
logger.info("[*] Organisation...")
org_fields = {
"DE_No": "depot_code",
"EB_No": "etablissement_code",
"MR_No": "mode_reglement_code",
"CAL_No": "calendrier_code",
}
for attr_sage, key_data in org_fields.items():
val = client_data.get(key_data)
if val is not None:
try:
setattr(client, attr_sage, safe_int(val))
except:
pass
if client_data.get("num_centrale"):
try:
client.CT_NumCentrale = clean_str(client_data["num_centrale"], LENGTHS["CT_NumCentrale"])
except:
pass
# ============================================================
# SURVEILLANCE (version compacte)
# ============================================================
logger.info("[*] Surveillance...")
if client_data.get("coface"):
try:
client.CT_Coface = clean_str(client_data["coface"], LENGTHS["CT_Coface"])
except:
pass
if client_data.get("surveillance_active") is not None:
try:
client.CT_Surveillance = safe_int(client_data["surveillance_active"])
except:
pass
forme_juri = client_data.get("forme_juridique") or client_data.get("sv_forme_juri")
if forme_juri:
try:
client.CT_SvFormeJuri = clean_str(forme_juri, LENGTHS["CT_SvFormeJuri"])
except:
pass
effectif = client_data.get("effectif") or client_data.get("sv_effectif")
if effectif:
try:
client.CT_SvEffectif = clean_str(effectif, LENGTHS["CT_SvEffectif"])
except:
pass
ca = client_data.get("sv_chiffre_affaires") or client_data.get("ca_annuel")
if ca is not None:
try:
client.CT_SvCA = safe_float(ca)
except:
pass
# (Je garde les autres sections mais en version compacte)
# ... [toutes les autres sections identiques à la version précédente] ...
# ============================================================
# DIAGNOSTIC PRE-WRITE
# ============================================================
logger.info("=" * 60)
logger.info("[?] DIAGNOSTIC PRE-WRITE")
diagnostic_fields = ["CT_Intitule", "CT_Num", "CT_Qualite", "CompteGPrinc"]
for champ in diagnostic_fields:
try:
if champ == "CompteGPrinc":
val = "Défini" if hasattr(client, champ) else None
else:
val = getattr(client, champ, None)
status = "[OK]" if val is not None else "[!!]"
logger.info(f" {status} {champ}: {val}")
except Exception as e:
logger.error(f" [ERR] {champ}: {e}")
try:
ct_type_actual = getattr(client, "CT_Type", None)
logger.info(f" [OK] CT_Type (auto): {ct_type_actual}")
except:
pass
# ============================================================
# ECRITURE
# ============================================================
logger.info("=" * 60)
logger.info("[>] Ecriture dans Sage (Write)...")
try:
client.Write()
logger.info("[OK] Write() reussi")
except Exception as e:
error_detail = str(e)
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = f"{sage_error.Description} (Code: {sage_error.Number})"
except:
pass
logger.error(f"[!!] Echec Write(): {error_detail}")
raise RuntimeError(f"Echec Write(): {error_detail}")
# ============================================================
# RELECTURE
# ============================================================
try:
client.Read()
except:
pass
num_final = getattr(client, "CT_Num", numero)
type_tiers_final = getattr(client, "CT_Type", type_tiers)
logger.info("=" * 60)
logger.info(f"[OK] TIERS CREE: {num_final} (Type: {type_tiers_final})")
logger.info("=" * 60)
return {
"numero": num_final,
"intitule": intitule,
"type_tiers": type_tiers_final,
"qualite": qualite,
"compte_general": compte,
"date_creation": datetime.now().isoformat(),
}
except ValueError as e:
logger.error(f"[!!] Erreur validation: {e}")
raise
except Exception as e:
logger.error(f"[!!] Erreur creation: {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 technique Sage: {error_message}")
def modifier_client(self, code: str, client_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
# ========================================
# ÉTAPE 1 : CHARGER LE CLIENT EXISTANT
# ========================================
logger.info(f" Recherche client {code}...")
factory_client = self.cial.CptaApplication.FactoryClient
persist = factory_client.ReadNumero(code)
if not persist:
raise ValueError(f"Client {code} introuvable")
client = win32com.client.CastTo(persist, "IBOClient3")
client.Read()
logger.info(
f" Client {code} trouvé: {getattr(client, 'CT_Intitule', '')}"
)
# ========================================
# ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS
# ========================================
logger.info(" Mise à jour des champs...")
champs_modifies = []
# Intitulé
if "intitule" in client_data:
intitule = str(client_data["intitule"])[:69].strip()
client.CT_Intitule = intitule
champs_modifies.append(f"intitule='{intitule}'")
# Adresse
if any(
k in client_data
for k in ["adresse", "code_postal", "ville", "pays"]
):
try:
adresse_obj = client.Adresse
if "adresse" in client_data:
adresse = str(client_data["adresse"])[:35].strip()
adresse_obj.Adresse = adresse
champs_modifies.append("adresse")
if "code_postal" in client_data:
cp = str(client_data["code_postal"])[:9].strip()
adresse_obj.CodePostal = cp
champs_modifies.append("code_postal")
if "ville" in client_data:
ville = str(client_data["ville"])[:35].strip()
adresse_obj.Ville = ville
champs_modifies.append("ville")
if "pays" in client_data:
pays = str(client_data["pays"])[:35].strip()
adresse_obj.Pays = pays
champs_modifies.append("pays")
except Exception as e:
logger.warning(f"Erreur mise à jour adresse: {e}")
# Télécom
if "email" in client_data or "telephone" in client_data:
try:
telecom_obj = client.Telecom
if "email" in client_data:
email = str(client_data["email"])[:69].strip()
telecom_obj.EMail = email
champs_modifies.append("email")
if "telephone" in client_data:
tel = str(client_data["telephone"])[:21].strip()
telecom_obj.Telephone = tel
champs_modifies.append("telephone")
except Exception as e:
logger.warning(f"Erreur mise à jour télécom: {e}")
# SIRET
if "siret" in client_data:
try:
siret = str(client_data["siret"])[:14].strip()
client.CT_Siret = siret
champs_modifies.append("siret")
except Exception as e:
logger.warning(f"Erreur mise à jour SIRET: {e}")
# TVA Intracommunautaire
if "tva_intra" in client_data:
try:
tva = str(client_data["tva_intra"])[:25].strip()
client.CT_Identifiant = tva
champs_modifies.append("tva_intra")
except Exception as e:
logger.warning(f"Erreur mise à jour TVA: {e}")
if not champs_modifies:
logger.warning("Aucun champ à modifier")
return self._extraire_client(client)
logger.info(f" Champs à modifier: {', '.join(champs_modifies)}")
# ========================================
# ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS
# ========================================
logger.info(" Écriture des modifications...")
try:
client.Write()
logger.info(" Write() réussi !")
except Exception as e:
error_detail = str(e)
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = (
f"{sage_error.Description} (Code: {sage_error.Number})"
)
except:
pass
logger.error(f" Erreur Write(): {error_detail}")
raise RuntimeError(f"Échec modification: {error_detail}")
# ========================================
# ÉTAPE 4 : RELIRE ET RETOURNER
# ========================================
client.Read()
logger.info(
f" CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs) "
)
# Refresh cache
return self._extraire_client(client)
except ValueError as e:
logger.error(f" Erreur métier: {e}")
raise
except Exception as e:
logger.error(f" Erreur modification client: {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage: {error_message}")
def creer_commande_enrichi(self, commande_data: dict) -> Dict:
"""
Crée une commande dans Sage avec support des dates.
Args:
commande_data: dict contenant:
- client: {code: str}
- date_commande: str ou date
- date_livraison: str ou date (optionnel)
- reference: str (optionnel)
- lignes: list[dict]
"""
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
logger.info(
f" Début création commande pour client {commande_data['client']['code']}"
)
try:
with self._com_context(), self._lock_com:
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug(" Transaction Sage démarrée")
except:
pass
try:
# ===== CRÉATION DOCUMENT COMMANDE (type 10) =====
process = self.cial.CreateProcess_Document(
settings.SAGE_TYPE_BON_COMMANDE
)
doc = process.Document
try:
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
except:
pass
logger.info(" Document commande créé")
# ===== DATES =====
doc.DO_Date = pywintypes.Time(
self.normaliser_date(commande_data.get("date_commande"))
)
if ("date_livraison" in commande_data and commande_data["date_livraison"]):
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(commande_data["date_livraison"])
)
logger.info(
f" Date livraison: {commande_data['date_livraison']}"
)
# ===== 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))
reference_finale = getattr(doc_final, "DO_Ref", "")
date_livraison_final = None
try:
date_livr = getattr(doc_final, "DO_DateLivr", None)
if date_livr:
date_livraison_final = date_livr.strftime("%Y-%m-%d")
except:
pass
else:
total_ht = 0.0
total_ttc = 0.0
reference_finale = commande_data.get("reference", "")
date_livraison_final = commande_data.get("date_livraison")
logger.info(
f" COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC "
)
if date_livraison_final:
logger.info(f" Date livraison: {date_livraison_final}")
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(
self.normaliser_date(commande_data.get("date_commande"))
),
"date_livraison": date_livraison_final,
"reference": reference_finale,
}
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
except:
pass
raise
except Exception as e:
logger.error(f" Erreur création commande: {e}", exc_info=True)
raise RuntimeError(f"Échec création commande: {str(e)}")
def modifier_commande(self, numero: str, commande_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
logger.info(f" === MODIFICATION COMMANDE {numero} ===")
# ========================================
# ÉTAPE 1 : CHARGER LE DOCUMENT
# ========================================
logger.info(" Chargement document...")
factory = self.cial.FactoryDocumentVente
persist = None
# Chercher le document
for type_test in [10, 3, settings.SAGE_TYPE_BON_COMMANDE]:
try:
persist_test = factory.ReadPiece(type_test, numero)
if persist_test:
persist = persist_test
logger.info(f" Document trouvé (type={type_test})")
break
except:
continue
if not persist:
raise ValueError(f" Commande {numero} INTROUVABLE")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
statut_actuel = getattr(doc, "DO_Statut", 0)
type_reel = getattr(doc, "DO_Type", -1)
logger.info(f" Type={type_reel}, Statut={statut_actuel}")
# ========================================
# ÉTAPE 2 : VÉRIFIER CLIENT INITIAL
# ========================================
client_code_initial = ""
try:
client_obj = getattr(doc, "Client", None)
if client_obj:
client_obj.Read()
client_code_initial = getattr(client_obj, "CT_Num", "").strip()
logger.info(f" Client initial: {client_code_initial}")
else:
logger.error(" Objet Client NULL à l'état initial !")
except Exception as e:
logger.error(f" Erreur lecture client initial: {e}")
if not client_code_initial:
raise ValueError(" Client introuvable dans le document")
# Compter les lignes initiales
nb_lignes_initial = 0
try:
factory_lignes = getattr(
doc, "FactoryDocumentLigne", None
) or getattr(doc, "FactoryDocumentVenteLigne", None)
index = 1
while index <= 100:
try:
ligne_p = factory_lignes.List(index)
if ligne_p is None:
break
nb_lignes_initial += 1
index += 1
except:
break
logger.info(f" Lignes initiales: {nb_lignes_initial}")
except Exception as e:
logger.warning(f" Erreur comptage lignes: {e}")
# ========================================
# ÉTAPE 3 : DÉTERMINER LES MODIFICATIONS
# ========================================
champs_modifies = []
modif_date = "date_commande" in commande_data
modif_date_livraison = "date_livraison" 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 commande: {modif_date}")
logger.info(f" Date livraison: {modif_date_livraison}")
logger.info(f" Statut: {modif_statut}")
logger.info(f" Référence: {modif_ref}")
logger.info(f" Lignes: {modif_lignes}")
# ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées =====
commande_data_temp = commande_data.copy()
reference_a_modifier = None
statut_a_modifier = None
if modif_lignes:
if modif_ref:
reference_a_modifier = commande_data_temp.pop("reference")
logger.info(
" Modification de la référence reportée après les lignes"
)
modif_ref = False
if modif_statut:
statut_a_modifier = commande_data_temp.pop("statut")
logger.info(
" Modification du statut reportée après les lignes"
)
modif_statut = False
# ========================================
# É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_date_livraison
or modif_statut
or modif_ref
):
logger.info(" Modifications simples (sans lignes)...")
if modif_date:
logger.info(" Modification date commande...")
doc.DO_Date = pywintypes.Time(
self.normaliser_date(
commande_data_temp.get("date_commande")
)
)
champs_modifies.append("date")
if modif_date_livraison:
logger.info(" Modification date livraison...")
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(commande_data_temp["date_livraison"])
)
logger.info(
f" Date livraison: {commande_data_temp['date_livraison']}"
)
champs_modifies.append("date_livraison")
if modif_statut:
logger.info(" Modification statut...")
nouveau_statut = commande_data_temp["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_temp["reference"]
champs_modifies.append("reference")
logger.info(
f" Référence définie: {commande_data_temp['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...")
# D'abord modifier les dates si demandées
if modif_date:
doc.DO_Date = pywintypes.Time(
self.normaliser_date(
commande_data_temp.get("date_commande")
)
)
champs_modifies.append("date")
logger.info(" Date commande modifiée")
if modif_date_livraison:
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(commande_data_temp["date_livraison"])
)
logger.info(" Date livraison modifiée")
champs_modifies.append("date_livraison")
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()
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")
import time
time.sleep(0.5)
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 6.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes)
# ========================================
if reference_a_modifier is not None:
try:
ancienne_reference = getattr(doc, "DO_Ref", "")
nouvelle_reference = (
str(reference_a_modifier) if reference_a_modifier else ""
)
doc.DO_Ref = nouvelle_reference
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("reference")
logger.info(
f" Référence modifiée: '{ancienne_reference}''{nouvelle_reference}'"
)
except Exception as e:
logger.warning(f"Impossible de modifier la référence: {e}")
# ========================================
# ÉTAPE 6.6 : MODIFIER STATUT (EN DERNIER)
# ========================================
if statut_a_modifier is not None:
try:
statut_actuel = getattr(doc, "DO_Statut", 0)
nouveau_statut = int(statut_a_modifier)
if nouveau_statut != statut_actuel:
doc.DO_Statut = nouveau_statut
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("statut")
logger.info(
f" Statut modifié: {statut_actuel}{nouveau_statut}"
)
except Exception as e:
logger.warning(f"Impossible de modifier le statut: {e}")
# ========================================
# ÉTAPE 7 : RELECTURE ET RETOUR
# ========================================
logger.info(" Relecture finale...")
import time
time.sleep(0.5)
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))
reference_finale = getattr(doc, "DO_Ref", "")
date_livraison_final = None
try:
date_livr = getattr(doc, "DO_DateLivr", None)
if date_livr:
date_livraison_final = date_livr.strftime("%Y-%m-%d")
except:
pass
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" Référence: {reference_finale}")
if date_livraison_final:
logger.info(f" Date livraison: {date_livraison_final}")
logger.info(f" Champs modifiés: {champs_modifies}")
return {
"numero": numero,
"total_ht": total_ht,
"total_ttc": total_ttc,
"reference": reference_finale,
"date_livraison": date_livraison_final,
"champs_modifies": champs_modifies,
"statut": getattr(doc, "DO_Statut", 0),
"client_code": client_final,
}
except ValueError as e:
logger.error(f" ERREUR MÉTIER: {e}")
raise
except Exception as e:
logger.error(f" ERREUR TECHNIQUE: {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = (
f"Erreur Sage: {err.Description} (Code: {err.Number})"
)
except:
pass
raise RuntimeError(f"Erreur Sage: {error_message}")
def creer_livraison_enrichi(self, livraison_data: dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
logger.info(
f" Début création livraison pour client {livraison_data['client']['code']}"
)
try:
with self._com_context(), self._lock_com:
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug(" Transaction Sage démarrée")
except:
pass
try:
# ===== CRÉATION DOCUMENT LIVRAISON (type 30) =====
process = self.cial.CreateProcess_Document(
settings.SAGE_TYPE_BON_LIVRAISON
)
doc = process.Document
try:
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
except:
pass
logger.info(" Document livraison créé")
# ===== DATES =====
# Date du document (DO_Date)
doc.DO_Date = pywintypes.Time(
self.normaliser_date(livraison_data.get("date_livraison"))
)
# Date de livraison prévue chez le client (DO_DateLivr)
if (
"date_livraison_prevue" in livraison_data
and livraison_data["date_livraison_prevue"]
):
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(
livraison_data["date_livraison_prevue"]
)
)
logger.info(
f" Date livraison prévue: {livraison_data['date_livraison_prevue']}"
)
# ===== 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))
reference_finale = getattr(doc_final, "DO_Ref", "")
# Dates
date_livraison_prevue_final = None
try:
date_livr = getattr(doc_final, "DO_DateLivr", None)
if date_livr:
date_livraison_prevue_final = date_livr.strftime(
"%Y-%m-%d"
)
except:
pass
else:
total_ht = 0.0
total_ttc = 0.0
reference_finale = livraison_data.get("reference", "")
date_livraison_prevue_final = livraison_data.get(
"date_livraison_prevue"
)
logger.info(
f" LIVRAISON CRÉÉE: {numero_livraison} - {total_ttc}€ TTC "
)
if date_livraison_prevue_final:
logger.info(
f" Date livraison prévue: {date_livraison_prevue_final}"
)
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(
self.normaliser_date(livraison_data.get("date_livraison"))
),
"date_livraison_prevue": date_livraison_prevue_final,
"reference": reference_finale,
}
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
except:
pass
raise
except Exception as e:
logger.error(f" Erreur création livraison: {e}", exc_info=True)
raise RuntimeError(f"Échec création livraison: {str(e)}")
def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
logger.info(f" === MODIFICATION LIVRAISON {numero} ===")
# ========================================
# ÉTAPE 1 : CHARGER LE DOCUMENT
# ========================================
logger.info(" Chargement document...")
factory = self.cial.FactoryDocumentVente
persist = None
# Chercher le document
for type_test in [30, settings.SAGE_TYPE_BON_LIVRAISON]:
try:
persist_test = factory.ReadPiece(type_test, numero)
if persist_test:
persist = persist_test
logger.info(f" Document trouvé (type={type_test})")
break
except:
continue
if not persist:
raise ValueError(f" Livraison {numero} INTROUVABLE")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
statut_actuel = getattr(doc, "DO_Statut", 0)
logger.info(f" Statut={statut_actuel}")
# Vérifier qu'elle n'est pas transformée
if statut_actuel == 5:
raise ValueError(f"La livraison {numero} a déjà été transformée")
if statut_actuel == 6:
raise ValueError(f"La livraison {numero} est annulée")
# Compter les lignes initiales
nb_lignes_initial = 0
try:
factory_lignes = getattr(
doc, "FactoryDocumentLigne", None
) or getattr(doc, "FactoryDocumentVenteLigne", None)
index = 1
while index <= 100:
try:
ligne_p = factory_lignes.List(index)
if ligne_p is None:
break
nb_lignes_initial += 1
index += 1
except:
break
logger.info(f" Lignes initiales: {nb_lignes_initial}")
except Exception as e:
logger.warning(f" Erreur comptage lignes: {e}")
# ========================================
# ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS
# ========================================
champs_modifies = []
modif_date = "date_livraison" in livraison_data
modif_date_livraison_prevue = "date_livraison_prevue" 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 livraison: {modif_date}")
logger.info(f" Date livraison prévue: {modif_date_livraison_prevue}")
logger.info(f" Statut: {modif_statut}")
logger.info(f" Référence: {modif_ref}")
logger.info(f" Lignes: {modif_lignes}")
# ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées =====
livraison_data_temp = livraison_data.copy()
reference_a_modifier = None
statut_a_modifier = None
if modif_lignes:
if modif_ref:
reference_a_modifier = livraison_data_temp.pop("reference")
logger.info(
" Modification de la référence reportée après les lignes"
)
modif_ref = False
if modif_statut:
statut_a_modifier = livraison_data_temp.pop("statut")
logger.info(
" Modification du statut reportée après les lignes"
)
modif_statut = False
# ========================================
# ÉTAPE 3 : MODIFICATIONS SIMPLES (pas de lignes)
# ========================================
if not modif_lignes and (
modif_date
or modif_date_livraison_prevue
or modif_statut
or modif_ref
):
logger.info(" Modifications simples (sans lignes)...")
if modif_date:
logger.info(" Modification date livraison...")
doc.DO_Date = pywintypes.Time(
self.normaliser_date(
livraison_data_temp.get("date_livraison")
)
)
champs_modifies.append("date")
if modif_date_livraison_prevue:
logger.info(" Modification date livraison prévue...")
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(
livraison_data_temp["date_livraison_prevue"]
)
)
logger.info(
f" Date livraison prévue: {livraison_data_temp['date_livraison_prevue']}"
)
champs_modifies.append("date_livraison_prevue")
if modif_statut:
logger.info(" Modification statut...")
nouveau_statut = livraison_data_temp["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 = livraison_data_temp["reference"]
champs_modifies.append("reference")
logger.info(
f" Référence définie: {livraison_data_temp['reference']}"
)
except Exception as e:
logger.warning(f" Référence non définie: {e}")
logger.info(" Write()...")
doc.Write()
logger.info(" Write() réussi")
# ========================================
# ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES
# ========================================
elif modif_lignes:
logger.info(" REMPLACEMENT COMPLET DES LIGNES...")
# D'abord modifier les dates si demandées
if modif_date:
doc.DO_Date = pywintypes.Time(
self.normaliser_date(
livraison_data_temp.get("date_livraison")
)
)
champs_modifies.append("date")
logger.info(" Date livraison modifiée")
if modif_date_livraison_prevue:
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(
livraison_data_temp["date_livraison_prevue"]
)
)
logger.info(" Date livraison prévue modifiée")
champs_modifies.append("date_livraison_prevue")
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
# ============================================
# SOUS-ÉTAPE 1 : SUPPRESSION TOUTES LES LIGNES
# ============================================
if nb_lignes_initial > 0:
logger.info(f" Suppression de {nb_lignes_initial} lignes...")
# 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()
ligne.Remove()
logger.debug(f" Ligne {idx} supprimée")
except Exception as e:
logger.warning(
f" Erreur suppression ligne {idx}: {e}"
)
# Continuer même si une suppression échoue
logger.info(" Toutes les lignes supprimées")
# ============================================
# SOUS-ÉTAPE 2 : AJOUT 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")
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("lignes")
# ========================================
# ÉTAPE 4.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes)
# ========================================
if reference_a_modifier is not None:
try:
ancienne_reference = getattr(doc, "DO_Ref", "")
nouvelle_reference = (
str(reference_a_modifier) if reference_a_modifier else ""
)
logger.info(
f" Modification référence: '{ancienne_reference}''{nouvelle_reference}'"
)
doc.DO_Ref = nouvelle_reference
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("reference")
logger.info(f" Référence modifiée avec succès")
except Exception as e:
logger.warning(f"Impossible de modifier la référence: {e}")
# ========================================
# ÉTAPE 4.6 : MODIFIER STATUT (EN DERNIER)
# ========================================
if statut_a_modifier is not None:
try:
statut_actuel = getattr(doc, "DO_Statut", 0)
nouveau_statut = int(statut_a_modifier)
if nouveau_statut != statut_actuel:
logger.info(
f" Modification statut: {statut_actuel}{nouveau_statut}"
)
doc.DO_Statut = nouveau_statut
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("statut")
logger.info(f" Statut modifié avec succès")
except Exception as e:
logger.warning(f"Impossible de modifier le statut: {e}")
# ========================================
# ÉTAPE 5 : RELECTURE ET RETOUR
# ========================================
logger.info(" Relecture finale...")
import time
time.sleep(0.5)
doc.Read()
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
reference_finale = getattr(doc, "DO_Ref", "")
statut_final = getattr(doc, "DO_Statut", 0)
# Extraire les dates
date_livraison_prevue_final = None
try:
date_livr = getattr(doc, "DO_DateLivr", None)
if date_livr:
date_livraison_prevue_final = date_livr.strftime("%Y-%m-%d")
except:
pass
logger.info(f" SUCCÈS: {numero} modifiée ")
logger.info(f" Totaux: {total_ht}€ HT / {total_ttc}€ TTC")
logger.info(f" Référence: {reference_finale}")
logger.info(f" Statut: {statut_final}")
if date_livraison_prevue_final:
logger.info(
f" Date livraison prévue: {date_livraison_prevue_final}"
)
logger.info(f" Champs modifiés: {champs_modifies}")
return {
"numero": numero,
"total_ht": total_ht,
"total_ttc": total_ttc,
"reference": reference_finale,
"date_livraison_prevue": date_livraison_prevue_final,
"champs_modifies": champs_modifies,
"statut": statut_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_avoir_enrichi(self, avoir_data: dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
logger.info(
f" Début création avoir pour client {avoir_data['client']['code']}"
)
try:
with self._com_context(), self._lock_com:
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug(" Transaction Sage démarrée")
except:
pass
try:
# ===== CRÉATION DOCUMENT AVOIR (type 50) =====
process = self.cial.CreateProcess_Document(
settings.SAGE_TYPE_BON_AVOIR
)
doc = process.Document
try:
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
except:
pass
logger.info(" Document avoir créé")
# ===== DATES =====
doc.DO_Date = pywintypes.Time(
self.normaliser_date(avoir_data.get("date_avoir"))
)
if "date_livraison" in avoir_data and avoir_data["date_livraison"]:
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(avoir_data["date_livraison"])
)
logger.info(
f" Date livraison: {avoir_data['date_livraison']}"
)
# ===== 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))
reference_finale = getattr(doc_final, "DO_Ref", "")
# Dates
date_livraison_final = None
try:
date_livr = getattr(doc_final, "DO_DateLivr", None)
if date_livr:
date_livraison_final = date_livr.strftime("%Y-%m-%d")
except:
pass
else:
total_ht = 0.0
total_ttc = 0.0
reference_finale = avoir_data.get("reference", "")
date_livraison_final = avoir_data.get("date_livraison")
logger.info(
f" AVOIR CRÉÉ: {numero_avoir} - {total_ttc}€ TTC "
)
if date_livraison_final:
logger.info(f" Date livraison: {date_livraison_final}")
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(
self.normaliser_date(avoir_data.get("date_avoir"))
),
"date_livraison": date_livraison_final,
"reference": reference_finale,
}
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
except:
pass
raise
except Exception as e:
logger.error(f" Erreur création avoir: {e}", exc_info=True)
raise RuntimeError(f"Échec création avoir: {str(e)}")
def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
logger.info(f" === MODIFICATION AVOIR {numero} ===")
# ========================================
# ÉTAPE 1 : CHARGER LE DOCUMENT
# ========================================
logger.info(" Chargement document...")
factory = self.cial.FactoryDocumentVente
persist = None
# Chercher le document
for type_test in [50, settings.SAGE_TYPE_BON_AVOIR]:
try:
persist_test = factory.ReadPiece(type_test, numero)
if persist_test:
persist = persist_test
logger.info(f" Document trouvé (type={type_test})")
break
except:
continue
if not persist:
raise ValueError(f" Avoir {numero} INTROUVABLE")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
statut_actuel = getattr(doc, "DO_Statut", 0)
type_reel = getattr(doc, "DO_Type", -1)
logger.info(f" Type={type_reel}, Statut={statut_actuel}")
# Vérifier qu'il n'est pas transformé ou annulé
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é")
# ========================================
# É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_avoir" in avoir_data
modif_date_livraison = "date_livraison" 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 avoir: {modif_date}")
logger.info(f" Date livraison: {modif_date_livraison}")
logger.info(f" Statut: {modif_statut}")
logger.info(f" Référence: {modif_ref}")
logger.info(f" Lignes: {modif_lignes}")
# ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées =====
avoir_data_temp = avoir_data.copy()
reference_a_modifier = None
statut_a_modifier = None
if modif_lignes:
if modif_ref:
reference_a_modifier = avoir_data_temp.pop("reference")
logger.info(
" Modification de la référence reportée après les lignes"
)
modif_ref = False
if modif_statut:
statut_a_modifier = avoir_data_temp.pop("statut")
logger.info(
" Modification du statut reportée après les lignes"
)
modif_statut = False
# ========================================
# É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_date_livraison
or modif_statut
or modif_ref
):
logger.info(" Modifications simples (sans lignes)...")
if modif_date:
logger.info(" Modification date avoir...")
doc.DO_Date = pywintypes.Time(
self.normaliser_date(avoir_data_temp.get("date_avoir"))
)
champs_modifies.append("date")
if modif_date_livraison:
logger.info(" Modification date livraison...")
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(avoir_data_temp["date_livraison"])
)
logger.info(
f" Date livraison: {avoir_data_temp['date_livraison']}"
)
champs_modifies.append("date_livraison")
if modif_statut:
logger.info(" Modification statut...")
nouveau_statut = avoir_data_temp["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 = avoir_data_temp["reference"]
champs_modifies.append("reference")
logger.info(
f" Référence définie: {avoir_data_temp['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...")
# D'abord modifier les dates si demandées
if modif_date:
doc.DO_Date = pywintypes.Time(
self.normaliser_date(avoir_data_temp.get("date_avoir"))
)
champs_modifies.append("date")
logger.info(" Date avoir modifiée")
if modif_date_livraison:
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(avoir_data_temp["date_livraison"])
)
logger.info(" Date livraison modifiée")
champs_modifies.append("date_livraison")
nouvelles_lignes = avoir_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()
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")
import time
time.sleep(0.5)
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 6.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes)
# ========================================
if reference_a_modifier is not None:
try:
ancienne_reference = getattr(doc, "DO_Ref", "")
nouvelle_reference = (
str(reference_a_modifier) if reference_a_modifier else ""
)
logger.info(
f" Modification référence: '{ancienne_reference}''{nouvelle_reference}'"
)
doc.DO_Ref = nouvelle_reference
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("reference")
logger.info(f" Référence modifiée avec succès")
except Exception as e:
logger.warning(f"Impossible de modifier la référence: {e}")
# ========================================
# ÉTAPE 6.6 : MODIFIER STATUT (EN DERNIER)
# ========================================
if statut_a_modifier is not None:
try:
statut_actuel = getattr(doc, "DO_Statut", 0)
nouveau_statut = int(statut_a_modifier)
if nouveau_statut != statut_actuel:
logger.info(
f" Modification statut: {statut_actuel}{nouveau_statut}"
)
doc.DO_Statut = nouveau_statut
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("statut")
logger.info(f" Statut modifié avec succès")
except Exception as e:
logger.warning(f"Impossible de modifier le statut: {e}")
# ========================================
# ÉTAPE 7 : RELECTURE ET RETOUR
# ========================================
logger.info(" Relecture finale...")
import time
time.sleep(0.5)
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))
reference_finale = getattr(doc, "DO_Ref", "")
statut_final = getattr(doc, "DO_Statut", 0)
# Extraire les dates
date_livraison_final = None
try:
date_livr = getattr(doc, "DO_DateLivr", None)
if date_livr:
date_livraison_final = date_livr.strftime("%Y-%m-%d")
except:
pass
logger.info(f" SUCCÈS: {numero} modifié ")
logger.info(f" Totaux: {total_ht}€ HT / {total_ttc}€ TTC")
logger.info(f" Client final: {client_final}")
logger.info(f" Référence: {reference_finale}")
logger.info(f" Statut: {statut_final}")
if date_livraison_final:
logger.info(f" Date livraison: {date_livraison_final}")
logger.info(f" Champs modifiés: {champs_modifies}")
return {
"numero": numero,
"total_ht": total_ht,
"total_ttc": total_ttc,
"reference": reference_finale,
"date_livraison": date_livraison_final,
"champs_modifies": champs_modifies,
"statut": statut_final,
"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_facture_enrichi(self, facture_data: dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
logger.info(
f" Début création facture pour client {facture_data['client']['code']}"
)
try:
with self._com_context(), self._lock_com:
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug(" Transaction Sage démarrée")
except:
pass
try:
# ===== CRÉATION DOCUMENT FACTURE (type 60) =====
process = self.cial.CreateProcess_Document(
settings.SAGE_TYPE_FACTURE
)
doc = process.Document
try:
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
except:
pass
logger.info(" Document facture créé")
# ===== DATES =====
doc.DO_Date = pywintypes.Time(
self.normaliser_date(facture_data.get("date_facture"))
)
if (
"date_livraison" in facture_data
and facture_data["date_livraison"]
):
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(facture_data["date_livraison"])
)
logger.info(
f" Date livraison: {facture_data['date_livraison']}"
)
# ===== 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))
reference_finale = getattr(doc_final, "DO_Ref", "")
# Dates
date_livraison_final = None
try:
date_livr = getattr(doc_final, "DO_DateLivr", None)
if date_livr:
date_livraison_final = date_livr.strftime("%Y-%m-%d")
except:
pass
else:
total_ht = 0.0
total_ttc = 0.0
reference_finale = facture_data.get("reference", "")
date_livraison_final = facture_data.get("date_livraison")
logger.info(
f" FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC "
)
if date_livraison_final:
logger.info(f" Date livraison: {date_livraison_final}")
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(
self.normaliser_date(facture_data.get("date_facture"))
),
"date_livraison": date_livraison_final,
"reference": reference_finale,
}
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
logger.error(" Transaction annulée (rollback)")
except:
pass
raise
except Exception as e:
logger.error(f" Erreur création facture: {e}", exc_info=True)
raise RuntimeError(f"Échec création facture: {str(e)}")
def modifier_facture(self, numero: str, facture_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
logger.info(f" === MODIFICATION FACTURE {numero} ===")
# ========================================
# ÉTAPE 1 : CHARGER LE DOCUMENT
# ========================================
logger.info(" Chargement document...")
factory = self.cial.FactoryDocumentVente
persist = None
# Chercher le document
for type_test in [60, 5, settings.SAGE_TYPE_FACTURE]:
try:
persist_test = factory.ReadPiece(type_test, numero)
if persist_test:
persist = persist_test
logger.info(f" Document trouvé (type={type_test})")
break
except:
continue
if not persist:
raise ValueError(f" Facture {numero} INTROUVABLE")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
statut_actuel = getattr(doc, "DO_Statut", 0)
type_reel = getattr(doc, "DO_Type", -1)
logger.info(f" Type={type_reel}, 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")
# ========================================
# É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_facture" in facture_data
modif_date_livraison = "date_livraison" 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 facture: {modif_date}")
logger.info(f" Date livraison: {modif_date_livraison}")
logger.info(f" Statut: {modif_statut}")
logger.info(f" Référence: {modif_ref}")
logger.info(f" Lignes: {modif_lignes}")
# ===== EXTRAIRE référence et statut pour les traiter à la fin si lignes modifiées =====
facture_data_temp = facture_data.copy()
reference_a_modifier = None
statut_a_modifier = None
if modif_lignes:
if modif_ref:
reference_a_modifier = facture_data_temp.pop("reference")
logger.info(
" Modification de la référence reportée après les lignes"
)
modif_ref = False
if modif_statut:
statut_a_modifier = facture_data_temp.pop("statut")
logger.info(
" Modification du statut reportée après les lignes"
)
modif_statut = False
# ========================================
# É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_date_livraison
or modif_statut
or modif_ref
):
logger.info(" Modifications simples (sans lignes)...")
if modif_date:
logger.info(" Modification date facture...")
doc.DO_Date = pywintypes.Time(
self.normaliser_date(facture_data_temp.get("date_facture"))
)
champs_modifies.append("date")
if modif_date_livraison:
logger.info(" Modification date livraison...")
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(facture_data_temp["date_livraison"])
)
logger.info(
f" Date livraison: {facture_data_temp['date_livraison']}"
)
champs_modifies.append("date_livraison")
if modif_statut:
logger.info(" Modification statut...")
nouveau_statut = facture_data_temp["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 = facture_data_temp["reference"]
champs_modifies.append("reference")
logger.info(
f" Référence définie: {facture_data_temp['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...")
# D'abord modifier les dates si demandées
if modif_date:
doc.DO_Date = pywintypes.Time(
self.normaliser_date(facture_data_temp.get("date_facture"))
)
champs_modifies.append("date")
logger.info(" Date facture modifiée")
if modif_date_livraison:
doc.DO_DateLivr = pywintypes.Time(
self.normaliser_date(facture_data_temp["date_livraison"])
)
logger.info(" Date livraison modifiée")
champs_modifies.append("date_livraison")
nouvelles_lignes = facture_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()
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")
import time
time.sleep(0.5)
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 6.5 : MODIFIER RÉFÉRENCE (APRÈS les lignes)
# ========================================
if reference_a_modifier is not None:
try:
ancienne_reference = getattr(doc, "DO_Ref", "")
nouvelle_reference = (
str(reference_a_modifier) if reference_a_modifier else ""
)
logger.info(
f" Modification référence: '{ancienne_reference}''{nouvelle_reference}'"
)
doc.DO_Ref = nouvelle_reference
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("reference")
logger.info(f" Référence modifiée avec succès")
except Exception as e:
logger.warning(f"Impossible de modifier la référence: {e}")
# ========================================
# ÉTAPE 6.6 : MODIFIER STATUT (EN DERNIER)
# ========================================
if statut_a_modifier is not None:
try:
statut_actuel = getattr(doc, "DO_Statut", 0)
nouveau_statut = int(statut_a_modifier)
if nouveau_statut != statut_actuel:
logger.info(
f" Modification statut: {statut_actuel}{nouveau_statut}"
)
doc.DO_Statut = nouveau_statut
doc.Write()
import time
time.sleep(0.5)
doc.Read()
champs_modifies.append("statut")
logger.info(f" Statut modifié avec succès")
except Exception as e:
logger.warning(f"Impossible de modifier le statut: {e}")
# ========================================
# ÉTAPE 7 : RELECTURE ET RETOUR
# ========================================
logger.info(" Relecture finale...")
import time
time.sleep(0.5)
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))
reference_finale = getattr(doc, "DO_Ref", "")
statut_final = getattr(doc, "DO_Statut", 0)
# Extraire les dates
date_livraison_final = None
try:
date_livr = getattr(doc, "DO_DateLivr", None)
if date_livr:
date_livraison_final = date_livr.strftime("%Y-%m-%d")
except:
pass
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" Référence: {reference_finale}")
logger.info(f" Statut: {statut_final}")
if date_livraison_final:
logger.info(f" Date livraison: {date_livraison_final}")
logger.info(f" Champs modifiés: {champs_modifies}")
return {
"numero": numero,
"total_ht": total_ht,
"total_ttc": total_ttc,
"reference": reference_finale,
"date_livraison": date_livraison_final,
"champs_modifies": champs_modifies,
"statut": statut_final,
"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_article(self, article_data: dict) -> dict:
with self._com_context(), self._lock_com:
try:
logger.info("[ARTICLE] === CREATION ARTICLE ===")
# Transaction
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug("Transaction Sage démarrée")
except Exception as e:
logger.debug(f"BeginTrans non disponible : {e}")
try:
# ========================================
# ÉTAPE 0 : DÉCOUVRIR DÉPÔTS
# ========================================
depots_disponibles = []
depot_a_utiliser = None
depot_code_demande = article_data.get("depot_code")
try:
factory_depot = self.cial.FactoryDepot
index = 1
while index <= 100:
try:
persist = factory_depot.List(index)
if persist is None:
break
depot_obj = win32com.client.CastTo(persist, "IBODepot3")
depot_obj.Read()
code = getattr(depot_obj, "DE_Code", "").strip()
if not code:
index += 1
continue
numero = int(getattr(depot_obj, "Compteur", 0))
intitule = getattr(
depot_obj, "DE_Intitule", f"Depot {code}"
)
depot_info = {
"code": code,
"numero": numero,
"intitule": intitule,
"objet": depot_obj,
}
depots_disponibles.append(depot_info)
if depot_code_demande and code == depot_code_demande:
depot_a_utiliser = depot_info
elif not depot_code_demande and not depot_a_utiliser:
depot_a_utiliser = depot_info
index += 1
except Exception as e:
if "Acces refuse" in str(e):
break
index += 1
except Exception as e:
logger.warning(f"[DEPOT] Erreur découverte dépôts : {e}")
if not depots_disponibles:
raise ValueError(
"Aucun dépôt trouvé dans Sage. Créez d'abord un dépôt."
)
if not depot_a_utiliser:
depot_a_utiliser = depots_disponibles[0]
logger.info(
f"[DEPOT] Utilisation : '{depot_a_utiliser['code']}' ({depot_a_utiliser['intitule']})"
)
# ========================================
# ÉTAPE 1 : VALIDATION & NETTOYAGE
# ========================================
reference = article_data.get("reference", "").upper().strip()
if not reference:
raise ValueError("La référence est obligatoire")
if len(reference) > 18:
raise ValueError(
"La référence ne peut pas dépasser 18 caractères"
)
designation = article_data.get("designation", "").strip()
if not designation:
raise ValueError("La désignation est obligatoire")
if len(designation) > 69:
designation = designation[:69]
# Récupération des STOCKS
stock_reel = article_data.get("stock_reel", 0.0)
stock_mini = article_data.get("stock_mini", 0.0)
stock_maxi = article_data.get("stock_maxi", 0.0)
logger.info(f"[ARTICLE] Référence : {reference}")
logger.info(f"[ARTICLE] Désignation : {designation}")
logger.info(f"[ARTICLE] Stock réel demandé : {stock_reel}")
logger.info(f"[ARTICLE] Stock mini demandé : {stock_mini}")
logger.info(f"[ARTICLE] Stock maxi demandé : {stock_maxi}")
# ========================================
# ÉTAPE 2 : VÉRIFIER SI EXISTE DÉJÀ
# ========================================
factory = self.cial.FactoryArticle
try:
article_existant = factory.ReadReference(reference)
if article_existant:
raise ValueError(f"L'article {reference} existe déjà")
except Exception as e:
error_msg = str(e)
if (
"Enregistrement non trouve" in error_msg
or "non trouve" in error_msg
or "-2607" in error_msg
):
logger.debug(
f"[ARTICLE] {reference} n'existe pas encore, création possible"
)
else:
logger.error(f"[ARTICLE] Erreur vérification : {e}")
raise
# ========================================
# ÉTAPE 3 : CRÉER L'ARTICLE
# ========================================
persist = factory.Create()
article = win32com.client.CastTo(persist, "IBOArticle3")
article.SetDefault()
# Champs de base
article.AR_Ref = reference
article.AR_Design = designation
# ========================================
# ÉTAPE 4 : TROUVER ARTICLE MODÈLE VIA SQL
# ========================================
logger.info("[MODELE] Recherche article modèle via SQL...")
article_modele_ref = None
article_modele = None
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT TOP 1 AR_Ref
FROM F_ARTICLE
WHERE AR_Sommeil = 0
ORDER BY AR_Ref
"""
)
row = cursor.fetchone()
if row:
article_modele_ref = self._safe_strip(row.AR_Ref)
logger.info(
f" [SQL] Article modèle trouvé : {article_modele_ref}"
)
except Exception as e:
logger.warning(f" [SQL] Erreur recherche article : {e}")
# Charger l'article modèle via COM
if article_modele_ref:
try:
persist_modele = factory.ReadReference(article_modele_ref)
if persist_modele:
article_modele = win32com.client.CastTo(
persist_modele, "IBOArticle3"
)
article_modele.Read()
logger.info(
f" [OK] Article modèle chargé : {article_modele_ref}"
)
except Exception as e:
logger.warning(f" [WARN] Erreur chargement modèle : {e}")
article_modele = None
if not article_modele:
raise ValueError(
"Aucun article modèle trouvé dans Sage.\n"
"Créez au moins un article manuellement dans Sage pour servir de modèle."
)
# ========================================
# ÉTAPE 5 : COPIER UNITÉ + FAMILLE
# ========================================
logger.info("[OBJETS] Copie Unite + Famille depuis modèle...")
# Unite
unite_trouvee = False
try:
unite_obj = getattr(article_modele, "Unite", None)
if unite_obj:
article.Unite = unite_obj
logger.info(
f" [OK] Objet Unite copié depuis {article_modele_ref}"
)
unite_trouvee = True
except Exception as e:
logger.debug(f" Unite non copiable : {str(e)[:80]}")
if not unite_trouvee:
raise ValueError(
"Impossible de copier l'unité de vente depuis le modèle"
)
# Famille
famille_trouvee = False
famille_code_personnalise = article_data.get("famille")
famille_obj = None
if famille_code_personnalise:
logger.info(
f" [FAMILLE] Code personnalisé demandé : {famille_code_personnalise}"
)
try:
# Vérifier existence via SQL
famille_existe_sql = False
famille_code_exact = None
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT FA_CodeFamille, FA_Type
FROM F_FAMILLE
WHERE UPPER(FA_CodeFamille) = ?
""",
(famille_code_personnalise.upper(),),
)
row = cursor.fetchone()
if row:
famille_code_exact = self._safe_strip(
row.FA_CodeFamille
)
famille_existe_sql = True
logger.info(
f" [SQL] Famille trouvée : {famille_code_exact}"
)
else:
raise ValueError(
f"Famille '{famille_code_personnalise}' introuvable"
)
except ValueError:
raise
except Exception as e_sql:
logger.warning(f" [SQL] Erreur : {e_sql}")
# Charger via COM
if famille_existe_sql and famille_code_exact:
factory_famille = self.cial.FactoryFamille
try:
index = 1
max_scan = 1000
while index <= max_scan:
try:
persist_test = factory_famille.List(index)
if persist_test is None:
break
fam_test = win32com.client.CastTo(
persist_test, "IBOFamille3"
)
fam_test.Read()
code_test = (
getattr(fam_test, "FA_CodeFamille", "")
.strip()
.upper()
)
if code_test == famille_code_exact.upper():
famille_obj = fam_test
famille_trouvee = True
logger.info(
f" [OK] Famille trouvée à l'index {index}"
)
break
index += 1
except Exception as e:
if "Accès refusé" in str(e):
break
index += 1
if famille_obj:
famille_obj.Read()
article.Famille = famille_obj
logger.info(
f" [OK] Famille '{famille_code_personnalise}' assignée"
)
else:
raise ValueError(
f"Famille '{famille_code_personnalise}' inaccessible via COM"
)
except Exception as e:
logger.warning(f" [COM] Erreur scanner : {e}")
raise
except ValueError:
raise
except Exception as e:
logger.warning(
f" [WARN] Famille personnalisée non chargée : {str(e)[:100]}"
)
# Si pas de famille perso, copier depuis le modèle
if not famille_trouvee:
try:
famille_obj = getattr(article_modele, "Famille", None)
if famille_obj:
article.Famille = famille_obj
logger.info(
f" [OK] Objet Famille copié depuis {article_modele_ref}"
)
famille_trouvee = True
except Exception as e:
logger.debug(f" Famille non copiable : {str(e)[:80]}")
# ========================================
# ÉTAPE 6 : CHAMPS OBLIGATOIRES
# ========================================
logger.info("[CHAMPS] Copie champs obligatoires depuis modèle...")
# Types et natures
article.AR_Type = int(getattr(article_modele, "AR_Type", 0))
article.AR_Nature = int(getattr(article_modele, "AR_Nature", 0))
article.AR_Nomencl = int(getattr(article_modele, "AR_Nomencl", 0))
# Suivi stock (forcé à 2 = FIFO/LIFO)
article.AR_SuiviStock = 2
logger.info(f" [OK] AR_SuiviStock=2 (FIFO/LIFO)")
# ========================================
# ÉTAPE 7 : PRIX
# ========================================
prix_vente = article_data.get("prix_vente")
if prix_vente is not None:
try:
article.AR_PrixVen = float(prix_vente)
logger.info(f" Prix vente : {prix_vente} EUR")
except Exception as e:
logger.warning(f" Prix vente erreur : {str(e)[:100]}")
prix_achat = article_data.get("prix_achat")
if prix_achat is not None:
try:
try:
article.AR_PrixAch = float(prix_achat)
logger.info(
f" Prix achat (AR_PrixAch) : {prix_achat} EUR"
)
except:
article.AR_PrixAchat = float(prix_achat)
logger.info(
f" Prix achat (AR_PrixAchat) : {prix_achat} EUR"
)
except Exception as e:
logger.warning(f" Prix achat erreur : {str(e)[:100]}")
# ========================================
# ÉTAPE 8 : CODE EAN
# ========================================
code_ean = article_data.get("code_ean")
if code_ean:
article.AR_CodeBarre = str(code_ean)
logger.info(f" Code EAN/Barre : {code_ean}")
# ========================================
# ÉTAPE 9 : DESCRIPTION
# ========================================
description = article_data.get("description")
if description:
try:
article.AR_Commentaire = description
logger.info(f" Description définie")
except:
pass
# ========================================
# ÉTAPE 10 : ÉCRITURE ARTICLE
# ========================================
logger.info("[ARTICLE] Écriture dans Sage...")
try:
article.Write()
logger.info(" [OK] Write() réussi")
except Exception as e:
error_detail = str(e)
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = f"{sage_error.Description} (Code: {sage_error.Number})"
except:
pass
logger.error(f" [ERREUR] Write() échoué : {error_detail}")
raise RuntimeError(f"Échec création article : {error_detail}")
# ========================================
# ÉTAPE 11 : DÉFINIR LE STOCK DANS F_ARTSTOCK (CRITIQUE)
# ========================================
stock_defini = False
stock_erreur = None
# Vérifier si on a des valeurs de stock à définir
has_stock_values = stock_reel or stock_mini or stock_maxi
if has_stock_values:
logger.info(
f"[STOCK] Définition stock dans F_ARTSTOCK (dépôt '{depot_a_utiliser['code']}')..."
)
try:
depot_obj = depot_a_utiliser["objet"]
# Chercher FactoryArticleStock ou FactoryDepotStock
factory_stock = None
for factory_name in [
"FactoryArticleStock",
"FactoryDepotStock",
]:
try:
factory_stock = getattr(
depot_obj, factory_name, None
)
if factory_stock:
logger.info(
f" Factory trouvée : {factory_name}"
)
break
except:
continue
if not factory_stock:
raise RuntimeError(
"Factory de stock introuvable sur le dépôt"
)
# Créer l'entrée de stock dans F_ARTSTOCK
stock_persist = factory_stock.Create()
stock_obj = win32com.client.CastTo(
stock_persist, "IBODepotStock3"
)
stock_obj.SetDefault()
# Référence article
stock_obj.AR_Ref = reference
# Stock réel
if stock_reel:
stock_obj.AS_QteSto = float(stock_reel)
logger.info(f" AS_QteSto = {stock_reel}")
# Stock minimum
if stock_mini:
try:
stock_obj.AS_QteMini = float(stock_mini)
logger.info(f" AS_QteMini = {stock_mini}")
except Exception as e:
logger.warning(f" AS_QteMini non défini : {e}")
# Stock maximum
if stock_maxi:
try:
stock_obj.AS_QteMaxi = float(stock_maxi)
logger.info(f" AS_QteMaxi = {stock_maxi}")
except Exception as e:
logger.warning(f" AS_QteMaxi non défini : {e}")
stock_obj.Write()
stock_defini = True
logger.info(
f" [OK] Stock défini dans F_ARTSTOCK : réel={stock_reel}, min={stock_mini}, max={stock_maxi}"
)
except Exception as e:
stock_erreur = str(e)
logger.error(
f" [ERREUR] Stock non défini dans F_ARTSTOCK : {e}",
exc_info=True,
)
# ========================================
# ÉTAPE 12 : COMMIT
# ========================================
if transaction_active:
try:
self.cial.CptaApplication.CommitTrans()
logger.info(
"[COMMIT] Transaction committée - Article persiste dans Sage"
)
except Exception as e:
logger.warning(f"[COMMIT] Erreur commit : {e}")
# ========================================
# ÉTAPE 13 : VÉRIFICATION & RELECTURE
# ========================================
logger.info("[VERIF] Relecture article créé...")
article_cree_persist = factory.ReadReference(reference)
if not article_cree_persist:
raise RuntimeError(
"Article créé mais introuvable à la relecture"
)
article_cree = win32com.client.CastTo(
article_cree_persist, "IBOArticle3"
)
article_cree.Read()
# ========================================
# ÉTAPE 14 : VÉRIFIER LE STOCK DANS F_ARTSTOCK VIA SQL
# ========================================
stocks_par_depot = []
stock_total = 0.0
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# Vérifier si le stock a été créé dans F_ARTSTOCK
cursor.execute(
"""
SELECT
d.DE_Code,
s.AS_QteSto,
s.AS_QteMini,
s.AS_QteMaxi
FROM F_ARTSTOCK s
LEFT JOIN F_DEPOT d ON s.DE_No = d.DE_No
WHERE s.AR_Ref = ?
""",
(reference.upper(),),
)
depot_rows = cursor.fetchall()
for depot_row in depot_rows:
if len(depot_row) >= 4:
qte = float(depot_row[1]) if depot_row[1] else 0.0
stock_total += qte
stocks_par_depot.append(
{
"depot_code": self._safe_strip(
depot_row[0]
),
"quantite": qte,
"qte_mini": (
float(depot_row[2])
if depot_row[2]
else 0.0
),
"qte_maxi": (
float(depot_row[3])
if depot_row[3]
else 0.0
),
}
)
logger.info(
f"[VERIF] Stock dans F_ARTSTOCK : {stock_total} unités dans {len(stocks_par_depot)} dépôt(s)"
)
except Exception as e:
logger.warning(
f"[VERIF] Impossible de vérifier le stock dans F_ARTSTOCK : {e}"
)
logger.info(
f"[OK] ARTICLE CREE : {reference} - Stock total : {stock_total}"
)
# ========================================
# ÉTAPE 15 : EXTRACTION COMPLÈTE
# ========================================
logger.info("[EXTRACTION] Extraction complète de l'article créé...")
# Utiliser _extraire_article() pour avoir TOUS les champs
resultat = self._extraire_article(article_cree)
if not resultat:
# Fallback si extraction échoue
resultat = {
"reference": reference,
"designation": designation,
}
# ========================================
# ÉTAPE 16 : FORCER LES VALEURS DE STOCK DEPUIS F_ARTSTOCK
# ========================================
# 1. STOCK (forcer les valeurs depuis F_ARTSTOCK)
resultat["stock_reel"] = stock_total
if stock_mini:
resultat["stock_mini"] = float(stock_mini)
if stock_maxi:
resultat["stock_maxi"] = float(stock_maxi)
# Stock disponible = stock réel (article neuf, pas de réservation)
resultat["stock_disponible"] = stock_total
resultat["stock_reserve"] = 0.0
resultat["stock_commande"] = 0.0
# 2. PRIX
if prix_vente is not None:
resultat["prix_vente"] = float(prix_vente)
if prix_achat is not None:
resultat["prix_achat"] = float(prix_achat)
# 3. DESCRIPTION
if description:
resultat["description"] = description
# 4. CODE EAN
if code_ean:
resultat["code_ean"] = str(code_ean)
resultat["code_barre"] = str(code_ean)
# 5. FAMILLE
if famille_code_personnalise and famille_trouvee:
resultat["famille_code"] = famille_code_personnalise
try:
if famille_obj:
famille_obj.Read()
resultat["famille_libelle"] = getattr(
famille_obj, "FA_Intitule", ""
)
except:
pass
# 6. INFOS DÉPÔTS
if stocks_par_depot:
resultat["stocks_par_depot"] = stocks_par_depot
resultat["depot_principal"] = {
"code": depot_a_utiliser["code"],
"intitule": depot_a_utiliser["intitule"],
}
# 7. SUIVI DE STOCK
resultat["suivi_stock_active"] = stock_defini
# 8. AVERTISSEMENT SI STOCK NON DÉFINI
if has_stock_values and not stock_defini and stock_erreur:
resultat["avertissement"] = (
f"Stock demandé mais non défini dans F_ARTSTOCK : {stock_erreur}"
)
logger.info(
f"[EXTRACTION] Article extrait et enrichi avec {len(resultat)} champs"
)
return resultat
except ValueError:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
except:
pass
raise
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
except:
pass
logger.error(f"Erreur creation article : {e}", exc_info=True)
raise RuntimeError(f"Erreur creation article : {str(e)}")
except Exception as e:
logger.error(f"Erreur globale : {e}", exc_info=True)
raise
def modifier_article(self, reference: str, article_data: Dict) -> Dict:
if not self.cial:
raise RuntimeError("Connexion Sage non établie")
try:
with self._com_context(), self._lock_com:
logger.info(f"[ARTICLE] === MODIFICATION {reference} ===")
# ========================================
# ÉTAPE 1 : CHARGER L'ARTICLE EXISTANT
# ========================================
factory_article = self.cial.FactoryArticle
persist = factory_article.ReadReference(reference.upper())
if not persist:
raise ValueError(f"Article {reference} introuvable")
article = win32com.client.CastTo(persist, "IBOArticle3")
article.Read()
designation_actuelle = getattr(article, "AR_Design", "")
logger.info(f"[ARTICLE] Trouvé : {reference} - {designation_actuelle}")
# ========================================
# ÉTAPE 2 : METTRE À JOUR LES CHAMPS
# ========================================
logger.info("[ARTICLE] Mise à jour des champs...")
champs_modifies = []
# ========================================
# 🆕 FAMILLE (NOUVEAU - avec scanner List)
# ========================================
if "famille" in article_data and article_data["famille"]:
famille_code_demande = article_data["famille"].upper().strip()
logger.info(
f"[FAMILLE] Changement demandé : {famille_code_demande}"
)
try:
# ========================================
# VÉRIFIER EXISTENCE VIA SQL
# ========================================
famille_existe_sql = False
famille_code_exact = None
famille_type = None
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT FA_CodeFamille, FA_Type
FROM F_FAMILLE
WHERE UPPER(FA_CodeFamille) = ?
""",
(famille_code_demande,),
)
row = cursor.fetchone()
if row:
famille_code_exact = self._safe_strip(
row.FA_CodeFamille
)
famille_type = row.FA_Type if len(row) > 1 else 0
famille_existe_sql = True
# Vérifier le type
if famille_type == 1:
raise ValueError(
f"La famille '{famille_code_demande}' est de type 'Total' "
f"et ne peut pas contenir d'articles. "
f"Utilisez une famille de type Détail."
)
logger.info(
f" [SQL] Famille trouvée : {famille_code_exact} (type={famille_type})"
)
else:
raise ValueError(
f"Famille '{famille_code_demande}' introuvable dans Sage"
)
except ValueError:
raise
except Exception as e:
logger.warning(f" [SQL] Erreur : {e}")
raise ValueError(f"Impossible de vérifier la famille : {e}")
# ========================================
# CHARGER VIA COM (SCANNER)
# ========================================
if famille_existe_sql and famille_code_exact:
logger.info(f" [COM] Recherche via scanner...")
factory_famille = self.cial.FactoryFamille
famille_obj = None
# Scanner List()
try:
index = 1
max_scan = 1000
while index <= max_scan:
try:
persist_test = factory_famille.List(index)
if persist_test is None:
break
fam_test = win32com.client.CastTo(
persist_test, "IBOFamille3"
)
fam_test.Read()
code_test = (
getattr(fam_test, "FA_CodeFamille", "")
.strip()
.upper()
)
if code_test == famille_code_exact.upper():
# TROUVÉ !
famille_obj = fam_test
logger.info(
f" [OK] Famille trouvée à l'index {index}"
)
break
index += 1
except Exception as e:
if "Accès refusé" in str(e) or "Access" in str(
e
):
break
index += 1
except Exception as e:
logger.warning(
f" [COM] Scanner échoué : {str(e)[:200]}"
)
# Assigner la famille
if famille_obj:
famille_obj.Read()
article.Famille = famille_obj
champs_modifies.append(f"famille={famille_code_exact}")
logger.info(
f" [OK] Famille changée : {famille_code_exact}"
)
else:
raise ValueError(
f"Famille '{famille_code_demande}' trouvée en SQL mais inaccessible via COM. "
f"Essayez avec une autre famille."
)
except ValueError:
raise
except Exception as e:
logger.error(f" [ERREUR] Changement famille : {e}")
raise ValueError(f"Impossible de changer la famille : {str(e)}")
# ========================================
# DÉSIGNATION
# ========================================
if "designation" in article_data:
designation = str(article_data["designation"])[:69].strip()
article.AR_Design = designation
champs_modifies.append(f"designation")
logger.info(f" [OK] Désignation : {designation}")
# ========================================
# PRIX DE VENTE
# ========================================
if "prix_vente" in article_data:
try:
prix_vente = float(article_data["prix_vente"])
article.AR_PrixVen = prix_vente
champs_modifies.append("prix_vente")
logger.info(f" [OK] Prix vente : {prix_vente} EUR")
except Exception as e:
logger.warning(f" [WARN] Prix vente : {e}")
# ========================================
# PRIX D'ACHAT
# ========================================
if "prix_achat" in article_data:
try:
prix_achat = float(article_data["prix_achat"])
# Double tentative (AR_PrixAch / AR_PrixAchat)
try:
article.AR_PrixAch = prix_achat
champs_modifies.append("prix_achat")
logger.info(
f" [OK] Prix achat (AR_PrixAch) : {prix_achat} EUR"
)
except:
article.AR_PrixAchat = prix_achat
champs_modifies.append("prix_achat")
logger.info(
f" [OK] Prix achat (AR_PrixAchat) : {prix_achat} EUR"
)
except Exception as e:
logger.warning(f" [WARN] Prix achat : {e}")
# ========================================
# STOCK RÉEL (NIVEAU ARTICLE)
# ========================================
if "stock_reel" in article_data:
try:
stock_reel = float(article_data["stock_reel"])
ancien_stock = float(getattr(article, "AR_Stock", 0.0))
article.AR_Stock = stock_reel
champs_modifies.append("stock_reel")
logger.info(f" [OK] Stock : {ancien_stock} -> {stock_reel}")
if stock_reel > ancien_stock:
logger.info(
f" [+] Stock augmenté de {stock_reel - ancien_stock}"
)
except Exception as e:
logger.error(f" [ERREUR] Stock : {e}")
raise ValueError(f"Impossible de modifier le stock: {e}")
# ========================================
# STOCK MINI/MAXI (NIVEAU ARTICLE)
# ========================================
if "stock_mini" in article_data:
try:
stock_mini = float(article_data["stock_mini"])
article.AR_StockMini = stock_mini
champs_modifies.append("stock_mini")
logger.info(f" [OK] Stock mini : {stock_mini}")
except Exception as e:
logger.warning(f" [WARN] Stock mini : {e}")
if "stock_maxi" in article_data:
try:
stock_maxi = float(article_data["stock_maxi"])
article.AR_StockMaxi = stock_maxi
champs_modifies.append("stock_maxi")
logger.info(f" [OK] Stock maxi : {stock_maxi}")
except Exception as e:
logger.warning(f" [WARN] Stock maxi : {e}")
# ========================================
# CODE EAN
# ========================================
if "code_ean" in article_data:
try:
code_ean = str(article_data["code_ean"])[:13].strip()
article.AR_CodeBarre = code_ean
champs_modifies.append("code_ean")
logger.info(f" [OK] Code EAN : {code_ean}")
except Exception as e:
logger.warning(f" [WARN] Code EAN : {e}")
# ========================================
# DESCRIPTION
# ========================================
if "description" in article_data:
try:
description = str(article_data["description"])[:255].strip()
article.AR_Commentaire = description
champs_modifies.append("description")
logger.info(f" [OK] Description définie")
except Exception as e:
logger.warning(f" [WARN] Description : {e}")
# ========================================
# VÉRIFICATION
# ========================================
if not champs_modifies:
logger.warning("[ARTICLE] Aucun champ à modifier")
return self._extraire_article(article)
logger.info(
f"[ARTICLE] Champs à modifier : {', '.join(champs_modifies)}"
)
# ========================================
# ÉCRITURE
# ========================================
logger.info("[ARTICLE] Écriture des modifications...")
try:
article.Write()
logger.info("[ARTICLE] Write() réussi")
except Exception as e:
error_detail = str(e)
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = (
f"{sage_error.Description} (Code: {sage_error.Number})"
)
except:
pass
logger.error(f"[ARTICLE] Erreur Write() : {error_detail}")
raise RuntimeError(f"Échec modification : {error_detail}")
# ========================================
# RELECTURE ET EXTRACTION
# ========================================
article.Read()
logger.info(
f"[ARTICLE] MODIFIÉ : {reference} ({len(champs_modifies)} champs)"
)
# Extraction complète
resultat = self._extraire_article(article)
if not resultat:
# Fallback si extraction échoue
resultat = {
"reference": reference,
"designation": getattr(article, "AR_Design", ""),
}
# Enrichir avec les valeurs qu'on vient de modifier
if "prix_vente" in article_data:
resultat["prix_vente"] = float(article_data["prix_vente"])
if "prix_achat" in article_data:
resultat["prix_achat"] = float(article_data["prix_achat"])
if "stock_reel" in article_data:
resultat["stock_reel"] = float(article_data["stock_reel"])
if "stock_mini" in article_data:
resultat["stock_mini"] = float(article_data["stock_mini"])
if "stock_maxi" in article_data:
resultat["stock_maxi"] = float(article_data["stock_maxi"])
if "code_ean" in article_data:
resultat["code_ean"] = str(article_data["code_ean"])
resultat["code_barre"] = str(article_data["code_ean"])
if "description" in article_data:
resultat["description"] = str(article_data["description"])
if "famille" in article_data:
resultat["famille_code"] = (
famille_code_exact if "famille_code_exact" in locals() else ""
)
return resultat
except ValueError as e:
logger.error(f"[ARTICLE] Erreur métier : {e}")
raise
except Exception as e:
logger.error(f"[ARTICLE] Erreur technique : {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage : {error_message}")
def creer_famille(self, famille_data: dict) -> dict:
with self._com_context(), self._lock_com:
try:
logger.info("[FAMILLE] === CRÉATION FAMILLE (TYPE DÉTAIL) ===")
# ========================================
# VALIDATION
# ========================================
code = famille_data.get("code", "").upper().strip()
if not code:
raise ValueError("Le code famille est obligatoire")
if len(code) > 18:
raise ValueError(
"Le code famille ne peut pas dépasser 18 caractères"
)
intitule = famille_data.get("intitule", "").strip()
if not intitule:
raise ValueError("L'intitulé est obligatoire")
if len(intitule) > 69:
intitule = intitule[:69]
logger.info(f"[FAMILLE] Code : {code}")
logger.info(f"[FAMILLE] Intitulé : {intitule}")
# NOUVEAU : Avertir si l'utilisateur demande un type Total
type_demande = famille_data.get("type", 0)
if type_demande == 1:
logger.warning(
"[FAMILLE] Type 'Total' demandé mais IGNORÉ - Création en type Détail uniquement"
)
# ========================================
# VÉRIFIER SI EXISTE DÉJÀ
# ========================================
factory_famille = self.cial.FactoryFamille
try:
# Scanner pour vérifier l'existence
index = 1
while index <= 1000:
try:
persist_test = factory_famille.List(index)
if persist_test is None:
break
fam_test = win32com.client.CastTo(
persist_test, "IBOFamille3"
)
fam_test.Read()
code_existant = (
getattr(fam_test, "FA_CodeFamille", "").strip().upper()
)
if code_existant == code:
raise ValueError(f"La famille {code} existe déjà")
index += 1
except ValueError:
raise # Re-raise si c'est notre erreur
except:
index += 1
except ValueError:
raise
# ========================================
# CRÉER LA FAMILLE
# ========================================
persist = factory_famille.Create()
famille = win32com.client.CastTo(persist, "IBOFamille3")
famille.SetDefault()
# Champs obligatoires
famille.FA_CodeFamille = code
famille.FA_Intitule = intitule
# CRITIQUE : FORCER Type = 0 (Détail)
try:
famille.FA_Type = 0 # Toujours Détail
logger.info(f"[FAMILLE] Type : 0 (Détail)")
except Exception as e:
logger.warning(f"[FAMILLE] FA_Type non défini : {e}")
# Comptes généraux (optionnels)
compte_achat = famille_data.get("compte_achat")
if compte_achat:
try:
factory_compte = self.cial.CptaApplication.FactoryCompteG
persist_compte = factory_compte.ReadNumero(compte_achat)
if persist_compte:
compte_obj = win32com.client.CastTo(
persist_compte, "IBOCompteG3"
)
compte_obj.Read()
famille.CompteGAchat = compte_obj
logger.info(f"[FAMILLE] Compte achat : {compte_achat}")
except Exception as e:
logger.warning(f"[FAMILLE] Compte achat non défini : {e}")
compte_vente = famille_data.get("compte_vente")
if compte_vente:
try:
factory_compte = self.cial.CptaApplication.FactoryCompteG
persist_compte = factory_compte.ReadNumero(compte_vente)
if persist_compte:
compte_obj = win32com.client.CastTo(
persist_compte, "IBOCompteG3"
)
compte_obj.Read()
famille.CompteGVente = compte_obj
logger.info(f"[FAMILLE] Compte vente : {compte_vente}")
except Exception as e:
logger.warning(f"[FAMILLE] Compte vente non défini : {e}")
# ========================================
# ÉCRIRE DANS SAGE
# ========================================
logger.info("[FAMILLE] Écriture dans Sage...")
try:
famille.Write()
logger.info("[FAMILLE] Write() réussi")
except Exception as e:
error_detail = str(e)
try:
sage_error = self.cial.CptaApplication.LastError
if sage_error:
error_detail = (
f"{sage_error.Description} (Code: {sage_error.Number})"
)
except:
pass
logger.error(f"[FAMILLE] Erreur Write() : {error_detail}")
raise RuntimeError(f"Échec création famille : {error_detail}")
# ========================================
# RELIRE ET RETOURNER
# ========================================
famille.Read()
resultat = {
"code": getattr(famille, "FA_CodeFamille", "").strip(),
"intitule": getattr(famille, "FA_Intitule", "").strip(),
"type": 0, # Toujours Détail
"type_libelle": "Détail",
}
logger.info(f"[FAMILLE] FAMILLE CRÉÉE : {code} - {intitule} (Détail)")
return resultat
except ValueError as e:
logger.error(f"[FAMILLE] Erreur métier : {e}")
raise
except Exception as e:
logger.error(f"[FAMILLE] Erreur technique : {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage : {error_message}")
def lister_toutes_familles(
self, filtre: str = "", inclure_totaux: bool = False
) -> List[Dict]:
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
logger.info("[SQL] Détection des colonnes de F_FAMILLE...")
# Requête de test pour récupérer les métadonnées
cursor.execute("SELECT TOP 1 * FROM F_FAMILLE")
colonnes_disponibles = [column[0] for column in cursor.description]
logger.info(f"[SQL] Colonnes trouvées : {len(colonnes_disponibles)}")
colonnes_souhaitees = [
"FA_CodeFamille",
"FA_Intitule",
"FA_Type",
"FA_UniteVen",
"FA_Coef",
"FA_Central",
"FA_Nature",
"CG_NumAch",
"CG_NumVte",
"FA_Stat",
"FA_Raccourci",
]
# Ne garder QUE les colonnes qui existent vraiment
colonnes_a_lire = [
col for col in colonnes_souhaitees if col in colonnes_disponibles
]
if not colonnes_a_lire:
colonnes_a_lire = colonnes_disponibles
logger.info(f"[SQL] Colonnes sélectionnées : {len(colonnes_a_lire)}")
colonnes_str = ", ".join(colonnes_a_lire)
query = f"""
SELECT {colonnes_str}
FROM F_FAMILLE
WHERE 1=1
"""
params = []
if "FA_Type" in colonnes_disponibles:
if not inclure_totaux:
query += " AND FA_Type = 0" # Seulement Détail
logger.info("[SQL] Filtre : FA_Type = 0 (Détail uniquement)")
else:
logger.info("[SQL] Filtre : TOUS les types (Détail + Total)")
# Filtre texte (si fourni)
if filtre:
conditions_filtre = []
if "FA_CodeFamille" in colonnes_a_lire:
conditions_filtre.append("FA_CodeFamille LIKE ?")
params.append(f"%{filtre}%")
if "FA_Intitule" in colonnes_a_lire:
conditions_filtre.append("FA_Intitule LIKE ?")
params.append(f"%{filtre}%")
if conditions_filtre:
query += " AND (" + " OR ".join(conditions_filtre) + ")"
# Tri
if "FA_Intitule" in colonnes_a_lire:
query += " ORDER BY FA_Intitule"
elif "FA_CodeFamille" in colonnes_a_lire:
query += " ORDER BY FA_CodeFamille"
cursor.execute(query, params)
rows = cursor.fetchall()
familles = []
for row in rows:
famille = {}
# Remplir avec les colonnes disponibles
for idx, colonne in enumerate(colonnes_a_lire):
valeur = row[idx]
if isinstance(valeur, str):
valeur = valeur.strip()
famille[colonne] = valeur
# Alias
if "FA_CodeFamille" in famille:
famille["code"] = famille["FA_CodeFamille"]
if "FA_Intitule" in famille:
famille["intitule"] = famille["FA_Intitule"]
# Type lisible
if "FA_Type" in famille:
type_val = famille["FA_Type"]
famille["type"] = type_val
famille["type_libelle"] = "Total" if type_val == 1 else "Détail"
famille["est_total"] = type_val == 1
else:
famille["type"] = 0
famille["type_libelle"] = "Détail"
famille["est_total"] = False
# Autres champs
famille["unite_vente"] = str(famille.get("FA_UniteVen", ""))
famille["coef"] = (
float(famille.get("FA_Coef", 0.0))
if famille.get("FA_Coef") is not None
else 0.0
)
famille["compte_achat"] = famille.get("CG_NumAch", "")
famille["compte_vente"] = famille.get("CG_NumVte", "")
famille["est_statistique"] = (
(famille.get("FA_Stat") == 1) if "FA_Stat" in famille else False
)
famille["est_centrale"] = (
(famille.get("FA_Central") == 1)
if "FA_Central" in famille
else False
)
famille["nature"] = famille.get("FA_Nature", 0)
familles.append(famille)
type_msg = "DÉTAIL uniquement" if not inclure_totaux else "TOUS types"
logger.info(f"SQL: {len(familles)} familles chargées ({type_msg})")
return familles
except Exception as e:
logger.error(f"Erreur SQL familles: {e}", exc_info=True)
raise RuntimeError(f"Erreur lecture familles: {str(e)}")
def lire_famille(self, code: str) -> Dict:
try:
with self._com_context(), self._lock_com:
logger.info(f"[FAMILLE] Lecture : {code}")
code_recherche = code.upper().strip()
famille_existe_sql = False
famille_code_exact = None
famille_type_sql = None
famille_intitule_sql = None
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# Détecter les colonnes disponibles
cursor.execute("SELECT TOP 1 * FROM F_FAMILLE")
colonnes_disponibles = [col[0] for col in cursor.description]
# Construire la requête selon les colonnes disponibles
colonnes_select = ["FA_CodeFamille", "FA_Intitule"]
if "FA_Type" in colonnes_disponibles:
colonnes_select.append("FA_Type")
colonnes_str = ", ".join(colonnes_select)
cursor.execute(
f"""
SELECT {colonnes_str}
FROM F_FAMILLE
WHERE UPPER(FA_CodeFamille) = ?
""",
(code_recherche,),
)
row = cursor.fetchone()
if row:
famille_existe_sql = True
famille_code_exact = self._safe_strip(row.FA_CodeFamille)
famille_intitule_sql = self._safe_strip(row.FA_Intitule)
# Type (si disponible)
if "FA_Type" in colonnes_disponibles and len(row) > 2:
famille_type_sql = row.FA_Type
logger.info(
f" [SQL] Trouvée : {famille_code_exact} - {famille_intitule_sql} (type={famille_type_sql})"
)
else:
raise ValueError(f"Famille '{code}' introuvable dans Sage")
except ValueError:
raise
except Exception as e:
logger.warning(f" [SQL] Erreur : {e}")
# Continuer quand même avec COM
if not famille_code_exact:
famille_code_exact = code_recherche
logger.info(
f" [COM] Recherche de '{famille_code_exact}' via scanner..."
)
factory_famille = self.cial.FactoryFamille
famille_obj = None
index_trouve = None
try:
index = 1
max_scan = 2000 # Scanner jusqu'à 2000 familles
while index <= max_scan:
try:
persist_test = factory_famille.List(index)
if persist_test is None:
break
fam_test = win32com.client.CastTo(
persist_test, "IBOFamille3"
)
fam_test.Read()
code_test = (
getattr(fam_test, "FA_CodeFamille", "").strip().upper()
)
if code_test == famille_code_exact:
# TROUVÉE !
famille_obj = fam_test
index_trouve = index
logger.info(f" [OK] Famille trouvée à l'index {index}")
break
index += 1
except Exception as e:
if "Accès refusé" in str(e) or "Access" in str(e):
break
index += 1
if not famille_obj:
if famille_existe_sql:
raise ValueError(
f"Famille '{code}' trouvée en SQL mais inaccessible via COM. "
f"Vérifiez les permissions."
)
else:
raise ValueError(f"Famille '{code}' introuvable")
except ValueError:
raise
except Exception as e:
logger.error(f" [COM] Erreur scanner : {e}")
raise RuntimeError(f"Erreur chargement famille : {str(e)}")
logger.info("[FAMILLE] Extraction des informations...")
famille_obj.Read()
# Champs de base
resultat = {
"code": getattr(famille_obj, "FA_CodeFamille", "").strip(),
"intitule": getattr(famille_obj, "FA_Intitule", "").strip(),
}
# Type
try:
fa_type = getattr(famille_obj, "FA_Type", 0)
resultat["type"] = fa_type
resultat["type_libelle"] = "Total" if fa_type == 1 else "Détail"
resultat["est_total"] = fa_type == 1
resultat["est_detail"] = fa_type == 0
# Avertissement si famille Total
if fa_type == 1:
resultat["avertissement"] = (
"Cette famille est de type 'Total' (agrégation comptable) "
"et ne peut pas contenir d'articles directement."
)
logger.warning(
f" [TYPE] Famille Total détectée : {resultat['code']}"
)
except:
resultat["type"] = 0
resultat["type_libelle"] = "Détail"
resultat["est_total"] = False
resultat["est_detail"] = True
# Unité de vente
try:
resultat["unite_vente"] = getattr(
famille_obj, "FA_UniteVen", ""
).strip()
except:
resultat["unite_vente"] = ""
# Coefficient
try:
coef = getattr(famille_obj, "FA_Coef", None)
resultat["coef"] = float(coef) if coef is not None else 0.0
except:
resultat["coef"] = 0.0
# Nature
try:
resultat["nature"] = getattr(famille_obj, "FA_Nature", 0)
except:
resultat["nature"] = 0
# Centrale d'achat
try:
central = getattr(famille_obj, "FA_Central", None)
resultat["est_centrale"] = (
(central == 1) if central is not None else False
)
except:
resultat["est_centrale"] = False
# Statistique
try:
stat = getattr(famille_obj, "FA_Stat", None)
resultat["est_statistique"] = (
(stat == 1) if stat is not None else False
)
except:
resultat["est_statistique"] = False
# Raccourci
try:
resultat["raccourci"] = getattr(
famille_obj, "FA_Raccourci", ""
).strip()
except:
resultat["raccourci"] = ""
# ========================================
# COMPTES GÉNÉRAUX
# ========================================
# Compte achat
try:
compte_achat_obj = getattr(famille_obj, "CompteGAchat", None)
if compte_achat_obj:
compte_achat_obj.Read()
resultat["compte_achat"] = getattr(
compte_achat_obj, "CG_Num", ""
).strip()
else:
resultat["compte_achat"] = ""
except:
resultat["compte_achat"] = ""
# Compte vente
try:
compte_vente_obj = getattr(famille_obj, "CompteGVente", None)
if compte_vente_obj:
compte_vente_obj.Read()
resultat["compte_vente"] = getattr(
compte_vente_obj, "CG_Num", ""
).strip()
else:
resultat["compte_vente"] = ""
except:
resultat["compte_vente"] = ""
# Index de lecture
resultat["index_com"] = index_trouve
# Dates (si disponibles)
try:
date_creation = getattr(famille_obj, "cbCreation", None)
resultat["date_creation"] = (
str(date_creation) if date_creation else ""
)
except:
resultat["date_creation"] = ""
try:
date_modif = getattr(famille_obj, "cbModification", None)
resultat["date_modification"] = (
str(date_modif) if date_modif else ""
)
except:
resultat["date_modification"] = ""
# Compter les articles de cette famille via SQL
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT COUNT(*)
FROM F_ARTICLE
WHERE FA_CodeFamille = ?
""",
(resultat["code"],),
)
row = cursor.fetchone()
if row:
resultat["nb_articles"] = row[0]
logger.info(
f" [STAT] {resultat['nb_articles']} article(s) dans cette famille"
)
except Exception as e:
logger.warning(f" [STAT] Impossible de compter les articles : {e}")
resultat["nb_articles"] = None
logger.info(
f"[FAMILLE] Lecture complète : {resultat['code']} - {resultat['intitule']}"
)
return resultat
except ValueError as e:
logger.error(f"[FAMILLE] Erreur métier : {e}")
raise
except Exception as e:
logger.error(f"[FAMILLE] Erreur technique : {e}", exc_info=True)
error_message = str(e)
if self.cial:
try:
err = self.cial.CptaApplication.LastError
if err:
error_message = f"Erreur Sage: {err.Description}"
except:
pass
raise RuntimeError(f"Erreur technique Sage : {error_message}")
def creer_entree_stock(self, entree_data: Dict) -> Dict:
try:
with self._com_context(), self._lock_com:
logger.info(f"[STOCK] === CRÉATION ENTRÉE STOCK (COM pur) ===")
# Démarrer transaction
transaction_active = False
try:
self.cial.CptaApplication.BeginTrans()
transaction_active = True
logger.debug("Transaction démarrée")
except:
pass
try:
# ========================================
# ÉTAPE 1 : CRÉER LE DOCUMENT D'ENTRÉE
# ========================================
factory_doc = self.cial.FactoryDocumentStock
persist_doc = factory_doc.CreateType(180) # 180 = Entrée
doc = win32com.client.CastTo(persist_doc, "IBODocumentStock3")
doc.SetDefault()
date_mouv = entree_data.get("date_mouvement")
if isinstance(date_mouv, date):
doc.DO_Date = pywintypes.Time(
datetime.combine(date_mouv, datetime.min.time())
)
else:
doc.DO_Date = pywintypes.Time(datetime.now())
if entree_data.get("reference"):
doc.DO_Ref = entree_data["reference"]
doc.Write()
logger.info(f"[STOCK] Document créé")
# ========================================
# ÉTAPE 2 : PRÉPARER POUR LES STOCKS MINI/MAXI
# ========================================
factory_article = self.cial.FactoryArticle
factory_depot = self.cial.FactoryDepot
stocks_mis_a_jour = []
depot_principal = None
# Trouver un dépôt principal
try:
persist_depot = factory_depot.List(1)
if persist_depot:
depot_principal = win32com.client.CastTo(
persist_depot, "IBODepot3"
)
depot_principal.Read()
logger.info(
f"Dépôt principal: {getattr(depot_principal, 'DE_Code', '?')}"
)
except Exception as e:
logger.warning(f"Erreur chargement dépôt: {e}")
# ========================================
# ÉTAPE 3 : TRAITER CHAQUE LIGNE (MOUVEMENT + STOCK)
# ========================================
try:
factory_lignes = doc.FactoryDocumentLigne
except:
factory_lignes = doc.FactoryDocumentStockLigne
for idx, ligne_data in enumerate(entree_data["lignes"], 1):
article_ref = ligne_data["article_ref"].upper()
quantite = ligne_data["quantite"]
stock_mini = ligne_data.get("stock_mini")
stock_maxi = ligne_data.get("stock_maxi")
logger.info(f"[STOCK] Ligne {idx}: {article_ref} x {quantite}")
# A. CHARGER L'ARTICLE
persist_article = factory_article.ReadReference(article_ref)
if not persist_article:
raise ValueError(f"Article {article_ref} introuvable")
article_obj = win32com.client.CastTo(
persist_article, "IBOArticle3"
)
article_obj.Read()
# B. CRÉER LA LIGNE DE MOUVEMENT
ligne_persist = factory_lignes.Create()
try:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
except:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentStockLigne3"
)
ligne_obj.SetDefault()
# Lier l'article au mouvement
try:
ligne_obj.SetDefaultArticleReference(
article_ref, float(quantite)
)
except:
try:
ligne_obj.SetDefaultArticle(
article_obj, float(quantite)
)
except:
raise ValueError(
f"Impossible de lier l'article {article_ref}"
)
# Prix
prix = ligne_data.get("prix_unitaire")
if prix:
try:
ligne_obj.DL_PrixUnitaire = float(prix)
except:
pass
# Écrire la ligne
ligne_obj.Write()
# ========================================
# ÉTAPE 4 : GÉRER LES STOCKS MINI/MAXI (COM PUR)
# ========================================
if stock_mini is not None or stock_maxi is not None:
logger.info(
f"[STOCK] Ajustement stock pour {article_ref}..."
)
try:
# MÉTHODE A : Via Article.FactoryArticleStock (LA BONNE MÉTHODE)
logger.info(
f" [COM] Méthode A : Article.FactoryArticleStock"
)
# 1. Charger l'article COMPLET avec sa factory
factory_article = self.cial.FactoryArticle
persist_article_full = factory_article.ReadReference(
article_ref
)
article_full = win32com.client.CastTo(
persist_article_full, "IBOArticle3"
)
article_full.Read()
# 2. Accéder à la FactoryArticleStock de l'article
factory_article_stock = None
try:
factory_article_stock = (
article_full.FactoryArticleStock
)
logger.info(" FactoryArticleStock trouvée")
except AttributeError:
logger.warning(
" FactoryArticleStock non disponible"
)
if factory_article_stock:
# 3. Chercher si le stock existe déjà
stock_trouve = None
index_stock = 1
while index_stock <= 100:
try:
stock_persist = factory_article_stock.List(
index_stock
)
if stock_persist is None:
break
stock_obj = win32com.client.CastTo(
stock_persist, "IBOArticleStock3"
)
stock_obj.Read()
# Vérifier le dépôt
depot_stock = None
try:
depot_stock = getattr(
stock_obj, "Depot", None
)
if depot_stock:
depot_stock.Read()
depot_code = getattr(
depot_stock, "DE_Code", ""
).strip()
logger.debug(
f" Dépôt {index_stock}: {depot_code}"
)
# Si c'est le dépôt principal ou le premier trouvé
if (
not stock_trouve
or depot_code
== getattr(
depot_principal,
"DE_Code",
"",
)
):
stock_trouve = stock_obj
logger.info(
f" Stock trouvé pour dépôt {depot_code}"
)
except:
pass
index_stock += 1
except Exception as e:
logger.debug(
f" Erreur stock {index_stock}: {e}"
)
index_stock += 1
# 4. Si pas trouvé, créer un nouveau stock
if not stock_trouve:
try:
stock_persist = (
factory_article_stock.Create()
)
stock_trouve = win32com.client.CastTo(
stock_persist, "IBOArticleStock3"
)
stock_trouve.SetDefault()
# Lier au dépôt principal si disponible
if depot_principal:
try:
stock_trouve.Depot = depot_principal
logger.info(
" Dépôt principal lié"
)
except:
pass
logger.info(" Nouvel ArticleStock créé")
except Exception as e:
logger.error(
f" Impossible de créer ArticleStock: {e}"
)
raise
# 5. METTRE À JOUR LES STOCKS MINI/MAXI
if stock_trouve:
# Sauvegarder l'état avant modification
try:
stock_trouve.Read()
except:
pass
# STOCK MINI
if stock_mini is not None:
try:
# Essayer différentes propriétés possibles
for prop_name in [
"AS_QteMini",
"AS_Mini",
"AR_StockMini",
"StockMini",
]:
try:
setattr(
stock_trouve,
prop_name,
float(stock_mini),
)
logger.info(
f" Stock mini défini via {prop_name}: {stock_mini}"
)
break
except AttributeError:
continue
except Exception as e:
logger.debug(
f" {prop_name} échoué: {e}"
)
continue
except Exception as e:
logger.warning(
f" Stock mini non défini: {e}"
)
# STOCK MAXI
if stock_maxi is not None:
try:
# Essayer différentes propriétés possibles
for prop_name in [
"AS_QteMaxi",
"AS_Maxi",
"AR_StockMaxi",
"StockMaxi",
]:
try:
setattr(
stock_trouve,
prop_name,
float(stock_maxi),
)
logger.info(
f" Stock maxi défini via {prop_name}: {stock_maxi}"
)
break
except AttributeError:
continue
except Exception as e:
logger.debug(
f" {prop_name} échoué: {e}"
)
continue
except Exception as e:
logger.warning(
f" Stock maxi non défini: {e}"
)
# 6. SAUVEGARDER
try:
stock_trouve.Write()
logger.info(
f" ArticleStock sauvegardé"
)
except Exception as e:
logger.error(
f" Erreur Write() ArticleStock: {e}"
)
raise
# MÉTHODE B : Alternative via DepotStock si A échoue
if depot_principal and (
stock_mini is not None or stock_maxi is not None
):
logger.info(
f" [COM] Méthode B : Depot.FactoryDepotStock (alternative)"
)
try:
factory_depot_stock = None
for factory_name in [
"FactoryDepotStock",
"FactoryArticleStock",
]:
try:
factory_depot_stock = getattr(
depot_principal, factory_name, None
)
if factory_depot_stock:
logger.info(
f" Factory trouvée: {factory_name}"
)
break
except:
continue
if factory_depot_stock:
# Chercher le stock existant
stock_depot_trouve = None
index_ds = 1
while index_ds <= 100:
try:
stock_ds_persist = (
factory_depot_stock.List(
index_ds
)
)
if stock_ds_persist is None:
break
stock_ds = win32com.client.CastTo(
stock_ds_persist,
"IBODepotStock3",
)
stock_ds.Read()
ar_ref_ds = (
getattr(stock_ds, "AR_Ref", "")
.strip()
.upper()
)
if ar_ref_ds == article_ref:
stock_depot_trouve = stock_ds
break
index_ds += 1
except:
index_ds += 1
# Si pas trouvé, créer
if not stock_depot_trouve:
try:
stock_ds_persist = (
factory_depot_stock.Create()
)
stock_depot_trouve = (
win32com.client.CastTo(
stock_ds_persist,
"IBODepotStock3",
)
)
stock_depot_trouve.SetDefault()
stock_depot_trouve.AR_Ref = (
article_ref
)
logger.info(
" Nouveau DepotStock créé"
)
except Exception as e:
logger.error(
f" Impossible de créer DepotStock: {e}"
)
# Mettre à jour
if stock_depot_trouve:
if stock_mini is not None:
try:
stock_depot_trouve.AS_QteMini = float(
stock_mini
)
logger.info(
f" DepotStock.AS_QteMini = {stock_mini}"
)
except Exception as e:
logger.warning(
f" DepotStock mini échoué: {e}"
)
if stock_maxi is not None:
try:
stock_depot_trouve.AS_QteMaxi = float(
stock_maxi
)
logger.info(
f" DepotStock.AS_QteMaxi = {stock_maxi}"
)
except Exception as e:
logger.warning(
f" DepotStock maxi échoué: {e}"
)
try:
stock_depot_trouve.Write()
logger.info(
" DepotStock sauvegardé"
)
except Exception as e:
logger.error(
f" DepotStock Write() échoué: {e}"
)
except Exception as e:
logger.warning(f" Méthode B échouée: {e}")
except Exception as e:
logger.error(
f"[STOCK] Erreur ajustement stock: {e}",
exc_info=True,
)
# Ne pas bloquer si l'ajustement échoue
stocks_mis_a_jour.append(
{
"article_ref": article_ref,
"quantite_ajoutee": quantite,
"stock_mini_defini": stock_mini,
"stock_maxi_defini": stock_maxi,
}
)
# ========================================
# ÉTAPE 5 : FINALISER LE DOCUMENT
# ========================================
doc.Write()
doc.Read()
numero = getattr(doc, "DO_Piece", "")
logger.info(f"[STOCK] Document finalisé: {numero}")
# ========================================
# ÉTAPE 6 : VÉRIFICATION VIA COM
# ========================================
logger.info(f"[STOCK] Vérification finale via COM...")
for stock_info in stocks_mis_a_jour:
article_ref = stock_info["article_ref"]
try:
# Recharger l'article pour voir les stocks
persist_article = factory_article.ReadReference(article_ref)
article_verif = win32com.client.CastTo(
persist_article, "IBOArticle3"
)
article_verif.Read()
# Lire les attributs de stock
stock_total = 0.0
stock_mini_lu = 0.0
stock_maxi_lu = 0.0
# Essayer différents attributs
for attr in ["AR_Stock", "AS_QteSto", "Stock"]:
try:
val = getattr(article_verif, attr, None)
if val is not None:
stock_total = float(val)
break
except:
pass
for attr in ["AR_StockMini", "AS_QteMini", "StockMini"]:
try:
val = getattr(article_verif, attr, None)
if val is not None:
stock_mini_lu = float(val)
break
except:
pass
for attr in ["AR_StockMaxi", "AS_QteMaxi", "StockMaxi"]:
try:
val = getattr(article_verif, attr, None)
if val is not None:
stock_maxi_lu = float(val)
break
except:
pass
logger.info(
f"[VERIF] {article_ref}: "
f"Total={stock_total}, "
f"Mini={stock_mini_lu}, "
f"Maxi={stock_maxi_lu}"
)
stock_info["stock_total_verifie"] = stock_total
stock_info["stock_mini_verifie"] = stock_mini_lu
stock_info["stock_maxi_verifie"] = stock_maxi_lu
except Exception as e:
logger.warning(
f"[VERIF] Erreur vérification {article_ref}: {e}"
)
# Commit
if transaction_active:
try:
self.cial.CptaApplication.CommitTrans()
logger.info(f"[STOCK] Transaction committée")
except:
logger.info(f"[STOCK] Changements sauvegardés")
return {
"article_ref": article_ref,
"numero": numero,
"type": 180,
"type_libelle": "Entrée en stock",
"date": str(getattr(doc, "DO_Date", "")),
"nb_lignes": len(stocks_mis_a_jour),
"stocks_mis_a_jour": stocks_mis_a_jour,
}
except Exception as e:
if transaction_active:
try:
self.cial.CptaApplication.RollbackTrans()
logger.info(f"[STOCK] Transaction annulée")
except:
pass
logger.error(f"[STOCK] ERREUR : {e}", exc_info=True)
raise ValueError(f"Erreur création entrée stock : {str(e)}")
except Exception as e:
logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True)
raise ValueError(f"Erreur création entrée stock : {str(e)}")
def lire_stock_article(self, reference: str, calcul_complet: bool = False) -> Dict:
try:
with self._com_context(), self._lock_com:
logger.info(f"[STOCK] Lecture stock : {reference}")
# Charger l'article
factory_article = self.cial.FactoryArticle
persist_article = factory_article.ReadReference(reference.upper())
if not persist_article:
raise ValueError(f"Article {reference} introuvable")
article = win32com.client.CastTo(persist_article, "IBOArticle3")
article.Read()
ar_suivi = getattr(article, "AR_SuiviStock", 0)
ar_design = getattr(article, "AR_Design", reference)
stock_info = {
"article": reference.upper(),
"designation": ar_design,
"stock_total": 0.0,
"suivi_stock": ar_suivi,
"suivi_libelle": {
0: "Aucun suivi",
1: "CMUP (sans lot)",
2: "FIFO/LIFO (avec lot)",
}.get(ar_suivi, f"Code {ar_suivi}"),
"depots": [],
"methode_lecture": None,
}
# ========================================
# MÉTHODE 1 : Via Depot.FactoryDepotStock (RAPIDE - 1-2 sec)
# ========================================
logger.info("[STOCK] Tentative Méthode 1 : Depot.FactoryDepotStock...")
try:
factory_depot = self.cial.FactoryDepot
index_depot = 1
stocks_trouves = []
# OPTIMISATION : Limiter à 20 dépôts max (au lieu de 100)
while index_depot <= 20:
try:
persist_depot = factory_depot.List(index_depot)
if persist_depot is None:
break
depot = win32com.client.CastTo(persist_depot, "IBODepot3")
depot.Read()
depot_code = ""
depot_intitule = ""
try:
depot_code = getattr(depot, "DE_Code", "").strip()
depot_intitule = getattr(
depot, "DE_Intitule", f"Dépôt {depot_code}"
)
except:
pass
if not depot_code:
index_depot += 1
continue
# Chercher FactoryDepotStock
factory_depot_stock = None
for factory_name in [
"FactoryDepotStock",
"FactoryArticleStock",
]:
try:
factory_depot_stock = getattr(
depot, factory_name, None
)
if factory_depot_stock:
break
except:
pass
if factory_depot_stock:
# OPTIMISATION : Limiter le scan à 1000 stocks par dépôt
index_stock = 1
while index_stock <= 1000:
try:
stock_persist = factory_depot_stock.List(
index_stock
)
if stock_persist is None:
break
stock = win32com.client.CastTo(
stock_persist, "IBODepotStock3"
)
stock.Read()
# Vérifier si c'est notre article
article_ref_stock = ""
# Essayer différents attributs
for attr_ref in [
"AR_Ref",
"AS_Article",
"Article_Ref",
]:
try:
val = getattr(stock, attr_ref, None)
if val:
article_ref_stock = (
str(val).strip().upper()
)
break
except:
pass
# Si pas trouvé, essayer via l'objet Article
if not article_ref_stock:
try:
article_obj = getattr(
stock, "Article", None
)
if article_obj:
article_obj.Read()
article_ref_stock = (
getattr(
article_obj, "AR_Ref", ""
)
.strip()
.upper()
)
except:
pass
if article_ref_stock == reference.upper():
# TROUVÉ !
quantite = 0.0
qte_mini = 0.0
qte_maxi = 0.0
# Essayer différents attributs de quantité
for attr_qte in [
"AS_QteSto",
"AS_Qte",
"QteSto",
"Quantite",
]:
try:
val = getattr(stock, attr_qte, None)
if val is not None:
quantite = float(val)
break
except:
pass
# Qte mini/maxi
try:
qte_mini = float(
getattr(stock, "AS_QteMini", 0.0)
)
except:
pass
try:
qte_maxi = float(
getattr(stock, "AS_QteMaxi", 0.0)
)
except:
pass
stocks_trouves.append(
{
"code": depot_code,
"intitule": depot_intitule,
"quantite": quantite,
"qte_mini": qte_mini,
"qte_maxi": qte_maxi,
}
)
logger.info(
f"[STOCK] Trouvé dépôt {depot_code} : {quantite} unités"
)
break
index_stock += 1
except Exception as e:
if "Accès refusé" in str(e):
break
index_stock += 1
index_depot += 1
except Exception as e:
if "Accès refusé" in str(e):
break
index_depot += 1
if stocks_trouves:
stock_info["depots"] = stocks_trouves
stock_info["stock_total"] = sum(
d["quantite"] for d in stocks_trouves
)
stock_info["methode_lecture"] = (
"Depot.FactoryDepotStock (RAPIDE)"
)
logger.info(
f"[STOCK] Méthode 1 réussie : {stock_info['stock_total']} unités"
)
return stock_info
except Exception as e:
logger.warning(f"[STOCK] Méthode 1 échouée : {e}")
# ========================================
# MÉTHODE 2 : Via attributs Article (RAPIDE - < 1 sec)
# ========================================
logger.info("[STOCK] Tentative Méthode 2 : Attributs Article...")
try:
stock_trouve = False
# Essayer différents attributs
for attr_stock in ["AR_Stock", "AR_QteSto", "AR_QteStock"]:
try:
val = getattr(article, attr_stock, None)
if val is not None:
stock_info["stock_total"] = float(val)
stock_info["methode_lecture"] = (
f"Article.{attr_stock} (RAPIDE)"
)
stock_trouve = True
logger.info(
f"[STOCK] Méthode 2 réussie via {attr_stock}"
)
break
except:
pass
if stock_trouve:
return stock_info
except Exception as e:
logger.warning(f"[STOCK] Méthode 2 échouée : {e}")
# ========================================
# MÉTHODE 3 : Calcul depuis mouvements (LENT - DÉSACTIVÉ PAR DÉFAUT)
# ========================================
if not calcul_complet:
# Méthodes rapides ont échoué, mais calcul complet non demandé
logger.warning(
f"[STOCK] Méthodes rapides échouées pour {reference}"
)
stock_info["methode_lecture"] = "ÉCHEC - Méthodes rapides échouées"
stock_info["stock_total"] = 0.0
stock_info["note"] = (
"Les méthodes rapides de lecture de stock ont échoué. "
"Options disponibles :\n"
"1. Utiliser GET /sage/diagnostic/stock-rapide-test/{reference} pour un diagnostic détaillé\n"
"2. Appeler lire_stock_article(reference, calcul_complet=True) pour un calcul depuis mouvements (LENT - 20+ min)\n"
"3. Vérifier que l'article a bien un suivi de stock activé (AR_SuiviStock)"
)
return stock_info
# ATTENTION : Cette partie est ULTRA-LENTE (20+ minutes)
logger.warning(
"[STOCK] CALCUL DEPUIS MOUVEMENTS ACTIVÉ - PEUT PRENDRE 20+ MINUTES"
)
# [Le reste du code de calcul depuis mouvements reste inchangé...]
# ... (code existant pour la méthode 3)
except ValueError:
raise
except Exception as e:
logger.error(f"[STOCK] Erreur lecture : {e}", exc_info=True)
raise ValueError(f"Erreur lecture stock : {str(e)}")
def creer_sortie_stock(self, sortie_data: Dict) -> Dict:
try:
with self._com_context(), self._lock_com:
logger.info(f"[STOCK] === CRÉATION SORTIE STOCK ===")
logger.info(f"[STOCK] {len(sortie_data.get('lignes', []))} ligne(s)")
try:
self.cial.CptaApplication.BeginTrans()
except:
pass
try:
# ========================================
# ÉTAPE 1 : CRÉER LE DOCUMENT
# ========================================
factory = self.cial.FactoryDocumentStock
persist = factory.CreateType(181) # 181 = Sortie
doc = win32com.client.CastTo(persist, "IBODocumentStock3")
doc.SetDefault()
# Date
date_mouv = sortie_data.get("date_mouvement")
if isinstance(date_mouv, date):
doc.DO_Date = pywintypes.Time(
datetime.combine(date_mouv, datetime.min.time())
)
else:
doc.DO_Date = pywintypes.Time(datetime.now())
# Référence
if sortie_data.get("reference"):
doc.DO_Ref = sortie_data["reference"]
doc.Write()
logger.info(
f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}"
)
# ========================================
# ÉTAPE 2 : FACTORY LIGNES
# ========================================
try:
factory_lignes = doc.FactoryDocumentLigne
except:
factory_lignes = doc.FactoryDocumentStockLigne
factory_article = self.cial.FactoryArticle
stocks_mis_a_jour = []
# ========================================
# ÉTAPE 3 : TRAITER CHAQUE LIGNE
# ========================================
for idx, ligne_data in enumerate(sortie_data["lignes"], 1):
article_ref = ligne_data["article_ref"].upper()
quantite = ligne_data["quantite"]
logger.info(
f"[STOCK] ======== LIGNE {idx} : {article_ref} x {quantite} ========"
)
# Charger l'article
persist_article = factory_article.ReadReference(article_ref)
if not persist_article:
raise ValueError(f"Article {article_ref} introuvable")
article_obj = win32com.client.CastTo(
persist_article, "IBOArticle3"
)
article_obj.Read()
ar_suivi = getattr(article_obj, "AR_SuiviStock", 0)
ar_design = getattr(article_obj, "AR_Design", article_ref)
logger.info(f"[STOCK] Article : {ar_design}")
logger.info(f"[STOCK] AR_SuiviStock : {ar_suivi}")
# VÉRIFIER LE STOCK DISPONIBLE
stock_dispo = self.verifier_stock_suffisant(
article_ref, quantite, None
)
if not stock_dispo["suffisant"]:
raise ValueError(
f"Stock insuffisant pour {article_ref} : "
f"disponible={stock_dispo['stock_disponible']}, "
f"demandé={quantite}"
)
logger.info(
f"[STOCK] Stock disponible : {stock_dispo['stock_disponible']}"
)
# Gérer le lot
numero_lot = ligne_data.get("numero_lot")
if ar_suivi == 1: # CMUP
if numero_lot:
logger.warning(f"[STOCK] CMUP : Suppression du lot")
numero_lot = None
elif ar_suivi == 2: # FIFO/LIFO
if not numero_lot:
import uuid
numero_lot = f"AUTO-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
logger.info(
f"[STOCK] FIFO/LIFO : Lot auto-généré '{numero_lot}'"
)
# ========================================
# CRÉER LA LIGNE
# ========================================
ligne_persist = factory_lignes.Create()
try:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentLigne3"
)
except:
ligne_obj = win32com.client.CastTo(
ligne_persist, "IBODocumentStockLigne3"
)
ligne_obj.SetDefault()
# ========================================
# LIAISON ARTICLE
# ========================================
article_lie = False
try:
ligne_obj.SetDefaultArticleReference(
article_ref, float(quantite)
)
article_lie = True
logger.info(f"[STOCK] SetDefaultArticleReference()")
except:
try:
ligne_obj.SetDefaultArticle(
article_obj, float(quantite)
)
article_lie = True
logger.info(f"[STOCK] SetDefaultArticle()")
except:
pass
if not article_lie:
raise ValueError(
f"Impossible de lier l'article {article_ref}"
)
# ========================================
# LOT (si FIFO/LIFO)
# ========================================
if numero_lot and ar_suivi == 2:
try:
ligne_obj.SetDefaultLot(numero_lot)
logger.info(f"[STOCK] Lot défini")
except:
try:
ligne_obj.LS_NoSerie = numero_lot
logger.info(f"[STOCK] Lot via LS_NoSerie")
except:
pass
# Prix
prix = ligne_data.get("prix_unitaire")
if prix:
try:
ligne_obj.DL_PrixUnitaire = float(prix)
except:
pass
# ========================================
# ÉCRIRE LA LIGNE
# ========================================
ligne_obj.Write()
logger.info(f"[STOCK] Write() réussi")
# Vérification
ligne_obj.Read()
ref_verifiee = article_ref # Supposer OK si Write() réussi
try:
article_lie_obj = getattr(ligne_obj, "Article", None)
if article_lie_obj:
article_lie_obj.Read()
ref_verifiee = (
getattr(article_lie_obj, "AR_Ref", "").strip()
or article_ref
)
except:
pass
logger.info(f"[STOCK] LIGNE {idx} COMPLÈTE")
stocks_mis_a_jour.append(
{
"article_ref": article_ref,
"quantite_retiree": quantite,
"reference_verifiee": ref_verifiee,
"stock_avant": stock_dispo["stock_disponible"],
"stock_apres": stock_dispo["stock_apres"],
"numero_lot": numero_lot if ar_suivi == 2 else None,
}
)
# ========================================
# FINALISER
# ========================================
doc.Write()
doc.Read()
numero = getattr(doc, "DO_Piece", "")
logger.info(f"[STOCK] Document finalisé : {numero}")
# Commit
try:
self.cial.CptaApplication.CommitTrans()
logger.info(f"[STOCK] Transaction committée")
except:
pass
return {
"numero": numero,
"type": 1,
"date": str(getattr(doc, "DO_Date", "")),
"nb_lignes": len(stocks_mis_a_jour),
"reference": sortie_data.get("reference"),
"stocks_mis_a_jour": stocks_mis_a_jour,
}
except Exception as e:
logger.error(f"[STOCK] ERREUR : {e}", exc_info=True)
try:
self.cial.CptaApplication.RollbackTrans()
except:
pass
raise ValueError(f"Erreur création sortie stock : {str(e)}")
except Exception as e:
logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True)
raise ValueError(f"Erreur création sortie stock : {str(e)}")
def lire_mouvement_stock(self, numero: str) -> Dict:
try:
with self._com_context(), self._lock_com:
factory = self.cial.FactoryDocumentStock
# Chercher le document
persist = None
index = 1
logger.info(f"[MOUVEMENT] Recherche de {numero}...")
while index < 10000:
try:
persist_test = factory.List(index)
if persist_test is None:
break
doc_test = win32com.client.CastTo(
persist_test, "IBODocumentStock3"
)
doc_test.Read()
if getattr(doc_test, "DO_Piece", "") == numero:
persist = persist_test
logger.info(f"[MOUVEMENT] Trouvé à l'index {index}")
break
index += 1
except:
index += 1
if not persist:
raise ValueError(f"Mouvement {numero} introuvable")
doc = win32com.client.CastTo(persist, "IBODocumentStock3")
doc.Read()
# Infos du document
do_type = getattr(doc, "DO_Type", -1)
types_mouvements = {
180: "Entrée",
181: "Sortie",
182: "Transfert",
183: "Inventaire",
}
mouvement = {
"numero": numero,
"type": do_type,
"type_libelle": types_mouvements.get(do_type, f"Type {do_type}"),
"date": str(getattr(doc, "DO_Date", "")),
"reference": getattr(doc, "DO_Ref", ""),
"lignes": [],
}
# Lire les lignes
try:
factory_lignes = getattr(
doc, "FactoryDocumentLigne", None
) or getattr(doc, "FactoryDocumentStockLigne", None)
if factory_lignes:
idx = 1
while idx <= 100:
try:
ligne_p = factory_lignes.List(idx)
if ligne_p is None:
break
try:
ligne = win32com.client.CastTo(
ligne_p, "IBODocumentLigne3"
)
except:
ligne = win32com.client.CastTo(
ligne_p, "IBODocumentStockLigne3"
)
ligne.Read()
# Récupérer la référence article via l'objet Article
article_ref = ""
try:
article_obj = getattr(ligne, "Article", None)
if article_obj:
article_obj.Read()
article_ref = getattr(
article_obj, "AR_Ref", ""
).strip()
except:
pass
ligne_info = {
"article_ref": article_ref,
"designation": getattr(ligne, "DL_Design", ""),
"quantite": float(getattr(ligne, "DL_Qte", 0.0)),
"prix_unitaire": float(
getattr(ligne, "DL_PrixUnitaire", 0.0)
),
"montant_ht": float(
getattr(ligne, "DL_MontantHT", 0.0)
),
"numero_lot": getattr(ligne, "LS_NoSerie", ""),
}
mouvement["lignes"].append(ligne_info)
idx += 1
except:
break
except Exception as e:
logger.warning(f"[MOUVEMENT] Erreur lecture lignes : {e}")
mouvement["nb_lignes"] = len(mouvement["lignes"])
logger.info(
f"[MOUVEMENT] Lu : {numero} - {mouvement['nb_lignes']} ligne(s)"
)
return mouvement
except ValueError:
raise
except Exception as e:
logger.error(f"[MOUVEMENT] Erreur : {e}", exc_info=True)
raise ValueError(f"Erreur lecture mouvement : {str(e)}")
def verifier_stock_suffisant(self, article_ref, quantite, depot=None):
"""Version thread-safe avec lock SQL"""
try:
with self._get_sql_connection() as conn:
cursor = conn.cursor()
# LOCK pour éviter les race conditions
cursor.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
cursor.execute("BEGIN TRANSACTION")
try:
# Lire stock avec lock
cursor.execute(
"""
SELECT SUM(AS_QteSto)
FROM F_ARTSTOCK WITH (UPDLOCK, ROWLOCK)
WHERE AR_Ref = ?
""",
(article_ref.upper(),),
)
row = cursor.fetchone()
stock_dispo = float(row[0]) if row and row[0] else 0.0
suffisant = stock_dispo >= quantite
cursor.execute("COMMIT")
return {
"suffisant": suffisant,
"stock_disponible": stock_dispo,
"quantite_demandee": quantite,
}
except:
cursor.execute("ROLLBACK")
raise
except Exception as e:
logger.error(f"Erreur vérification stock: {e}")
raise