10722 lines
456 KiB
Python
10722 lines
456 KiB
Python
import win32com.client
|
||
import pythoncom # AJOUT CRITIQUE
|
||
from datetime import datetime, timedelta, date
|
||
from typing import Dict, List, Optional
|
||
import threading
|
||
import time
|
||
import logging
|
||
from config import settings, validate_settings
|
||
import pyodbc
|
||
from contextlib import contextmanager
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class SageConnector:
|
||
def __init__(self, chemin_base, utilisateur="<Administrateur>", mot_de_passe=""):
|
||
self.chemin_base = chemin_base
|
||
self.utilisateur = utilisateur
|
||
self.mot_de_passe = mot_de_passe
|
||
self.cial = None
|
||
|
||
self.sql_server = "OV-FDDDC6\\SAGE100"
|
||
self.sql_database = "BIJOU"
|
||
self.sql_conn_string = (
|
||
f"DRIVER={{ODBC Driver 17 for SQL Server}};"
|
||
f"SERVER={self.sql_server};"
|
||
f"DATABASE={self.sql_database};"
|
||
f"Trusted_Connection=yes;"
|
||
f"Encrypt=no;"
|
||
)
|
||
|
||
self._lock_com = threading.RLock()
|
||
|
||
# Thread-local storage pour COM
|
||
self._thread_local = threading.local()
|
||
|
||
# =========================================================================
|
||
# GESTION COM THREAD-SAFE
|
||
# =========================================================================
|
||
|
||
@contextmanager
|
||
def _com_context(self):
|
||
# Vérifier si COM est déjà initialisé pour ce thread
|
||
if not hasattr(self._thread_local, "com_initialized"):
|
||
try:
|
||
pythoncom.CoInitialize()
|
||
self._thread_local.com_initialized = True
|
||
logger.debug(
|
||
f"COM initialisé pour thread {threading.current_thread().name}"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Erreur initialisation COM: {e}")
|
||
raise
|
||
|
||
try:
|
||
yield
|
||
finally:
|
||
# Ne pas désinitialiser COM ici car le thread peut être réutilisé
|
||
pass
|
||
|
||
@contextmanager
|
||
def _get_sql_connection(self):
|
||
"""Context manager pour connexions SQL"""
|
||
conn = None
|
||
try:
|
||
conn = pyodbc.connect(self.sql_conn_string, timeout=10)
|
||
yield conn
|
||
except pyodbc.Error as e:
|
||
logger.error(f"❌ Erreur SQL: {e}")
|
||
raise RuntimeError(f"Erreur SQL: {str(e)}")
|
||
finally:
|
||
if conn:
|
||
conn.close()
|
||
|
||
def _safe_strip(self, value):
|
||
"""Strip sécurisé pour valeurs SQL"""
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, str):
|
||
return value.strip()
|
||
return value
|
||
|
||
def _cleanup_com_thread(self):
|
||
"""Nettoie COM pour le thread actuel (à appeler à la fin)"""
|
||
if hasattr(self._thread_local, "com_initialized"):
|
||
try:
|
||
pythoncom.CoUninitialize()
|
||
delattr(self._thread_local, "com_initialized")
|
||
logger.debug(
|
||
f"COM nettoyé pour thread {threading.current_thread().name}"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
# =========================================================================
|
||
# CONNEXION
|
||
# =========================================================================
|
||
|
||
def connecter(self):
|
||
"""Connexion initiale à Sage - VERSION HYBRIDE"""
|
||
try:
|
||
# ========================================
|
||
# CONNEXION COM (pour écritures)
|
||
# ========================================
|
||
with self._com_context():
|
||
self.cial = win32com.client.gencache.EnsureDispatch(
|
||
"Objets100c.Cial.Stream"
|
||
)
|
||
self.cial.Name = self.chemin_base
|
||
self.cial.Loggable.UserName = self.utilisateur
|
||
self.cial.Loggable.UserPwd = self.mot_de_passe
|
||
self.cial.Open()
|
||
|
||
logger.info(f"✅ Connexion COM Sage réussie: {self.chemin_base}")
|
||
|
||
# ========================================
|
||
# TEST CONNEXION SQL (pour lectures)
|
||
# ========================================
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT COUNT(*) FROM F_COMPTET")
|
||
nb_tiers = cursor.fetchone()[0]
|
||
logger.info(f"✅ Connexion SQL réussie: {nb_tiers} tiers détectés")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ SQL non disponible: {e}")
|
||
logger.warning(" Les lectures utiliseront COM (plus lent)")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur connexion Sage: {e}", exc_info=True)
|
||
return False
|
||
|
||
def deconnecter(self):
|
||
"""Déconnexion propre"""
|
||
if self.cial:
|
||
try:
|
||
with self._com_context():
|
||
self.cial.Close()
|
||
logger.info("Connexion Sage fermée")
|
||
except:
|
||
pass
|
||
|
||
def lister_tous_fournisseurs(self, filtre=""):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
query = """
|
||
SELECT
|
||
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
|
||
CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays,
|
||
CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant,
|
||
CT_Sommeil, CT_Contact
|
||
FROM F_COMPTET
|
||
WHERE CT_Type = 1
|
||
"""
|
||
|
||
params = []
|
||
|
||
if filtre:
|
||
query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)"
|
||
params.extend([f"%{filtre}%", f"%{filtre}%"])
|
||
|
||
query += " ORDER BY CT_Intitule"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
fournisseurs = []
|
||
for row in rows:
|
||
fournisseurs.append(
|
||
{
|
||
"numero": self._safe_strip(row.CT_Num),
|
||
"intitule": self._safe_strip(row.CT_Intitule),
|
||
"type": 1, # Fournisseur
|
||
"est_fournisseur": True,
|
||
"qualite": self._safe_strip(row.CT_Qualite),
|
||
"adresse": self._safe_strip(row.CT_Adresse),
|
||
"ville": self._safe_strip(row.CT_Ville),
|
||
"code_postal": self._safe_strip(row.CT_CodePostal),
|
||
"pays": self._safe_strip(row.CT_Pays),
|
||
"telephone": self._safe_strip(row.CT_Telephone),
|
||
"email": self._safe_strip(row.CT_EMail),
|
||
"siret": self._safe_strip(row.CT_Siret),
|
||
"tva_intra": self._safe_strip(row.CT_Identifiant),
|
||
"est_actif": (row.CT_Sommeil == 0),
|
||
"contact": self._safe_strip(row.CT_Contact),
|
||
}
|
||
)
|
||
|
||
logger.info(f"✅ SQL: {len(fournisseurs)} fournisseurs")
|
||
return fournisseurs
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL fournisseurs: {e}")
|
||
return []
|
||
|
||
def lire_fournisseur(self, code):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
|
||
CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays,
|
||
CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie,
|
||
CT_Siret, CT_Identifiant, CT_Sommeil,
|
||
CT_Contact, CT_FormeJuridique
|
||
FROM F_COMPTET
|
||
WHERE CT_Num = ? AND CT_Type = 1
|
||
""",
|
||
(code.upper(),),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
return {
|
||
"numero": self._safe_strip(row.CT_Num),
|
||
"intitule": self._safe_strip(row.CT_Intitule),
|
||
"type": 1,
|
||
"est_fournisseur": True,
|
||
"qualite": self._safe_strip(row.CT_Qualite),
|
||
"adresse": self._safe_strip(row.CT_Adresse),
|
||
"complement": self._safe_strip(row.CT_Complement),
|
||
"ville": self._safe_strip(row.CT_Ville),
|
||
"code_postal": self._safe_strip(row.CT_CodePostal),
|
||
"pays": self._safe_strip(row.CT_Pays),
|
||
"telephone": self._safe_strip(row.CT_Telephone),
|
||
"portable": self._safe_strip(row.CT_Portable),
|
||
"email": self._safe_strip(row.CT_EMail),
|
||
"telecopie": self._safe_strip(row.CT_Telecopie),
|
||
"siret": self._safe_strip(row.CT_Siret),
|
||
"tva_intra": self._safe_strip(row.CT_Identifiant),
|
||
"est_actif": (row.CT_Sommeil == 0),
|
||
"contact": self._safe_strip(row.CT_Contact),
|
||
"forme_juridique": self._safe_strip(row.CT_FormeJuridique),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL fournisseur {code}: {e}")
|
||
return None
|
||
|
||
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
# ========================================
|
||
# ÉTAPE 0 : VALIDATION & NETTOYAGE
|
||
# ========================================
|
||
logger.info("🔍 === VALIDATION DONNÉES FOURNISSEUR ===")
|
||
|
||
if not fournisseur_data.get("intitule"):
|
||
raise ValueError("Le champ 'intitule' est obligatoire")
|
||
|
||
# Nettoyage et troncature (longueurs max Sage)
|
||
intitule = str(fournisseur_data["intitule"])[:69].strip()
|
||
num_prop = (
|
||
str(fournisseur_data.get("num", "")).upper()[:17].strip()
|
||
if fournisseur_data.get("num")
|
||
else ""
|
||
)
|
||
compte = str(fournisseur_data.get("compte_collectif", "401000"))[
|
||
:13
|
||
].strip()
|
||
|
||
adresse = str(fournisseur_data.get("adresse", ""))[:35].strip()
|
||
code_postal = str(fournisseur_data.get("code_postal", ""))[:9].strip()
|
||
ville = str(fournisseur_data.get("ville", ""))[:35].strip()
|
||
pays = str(fournisseur_data.get("pays", ""))[:35].strip()
|
||
|
||
telephone = str(fournisseur_data.get("telephone", ""))[:21].strip()
|
||
email = str(fournisseur_data.get("email", ""))[:69].strip()
|
||
|
||
siret = str(fournisseur_data.get("siret", ""))[:14].strip()
|
||
tva_intra = str(fournisseur_data.get("tva_intra", ""))[:25].strip()
|
||
|
||
logger.info(f" intitule: '{intitule}' (len={len(intitule)})")
|
||
logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})")
|
||
logger.info(f" compte: '{compte}' (len={len(compte)})")
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : CRÉATION OBJET FOURNISSEUR
|
||
# ========================================
|
||
# 🔑 CRITIQUE: Utiliser FactoryFournisseur, PAS FactoryClient !
|
||
factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur
|
||
|
||
persist = factory_fournisseur.Create()
|
||
fournisseur = win32com.client.CastTo(persist, "IBOFournisseur3")
|
||
|
||
# 🔑 CRITIQUE : Initialiser l'objet
|
||
fournisseur.SetDefault()
|
||
|
||
logger.info("✅ Objet fournisseur créé et initialisé")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : CHAMPS OBLIGATOIRES
|
||
# ========================================
|
||
logger.info("📝 Définition des champs obligatoires...")
|
||
|
||
# 1. Intitulé (OBLIGATOIRE)
|
||
fournisseur.CT_Intitule = intitule
|
||
logger.debug(f" ✅ CT_Intitule: '{intitule}'")
|
||
|
||
# 2. Type = Fournisseur (1)
|
||
# ⚠️ NOTE: Sur certaines versions Sage, CT_Type n'existe pas
|
||
# et le type est automatiquement défini par la factory utilisée
|
||
try:
|
||
fournisseur.CT_Type = 1 # 1 = Fournisseur
|
||
logger.debug(" ✅ CT_Type: 1 (Fournisseur)")
|
||
except:
|
||
logger.debug(" ⚠️ CT_Type non défini (géré par FactoryFournisseur)")
|
||
|
||
# 3. Qualité (pour versions récentes Sage)
|
||
try:
|
||
fournisseur.CT_Qualite = "FOU"
|
||
logger.debug(" ✅ CT_Qualite: 'FOU'")
|
||
except:
|
||
logger.debug(" ⚠️ CT_Qualite non défini (pas critique)")
|
||
|
||
# 4. Compte général principal (OBLIGATOIRE)
|
||
try:
|
||
factory_compte = self.cial.CptaApplication.FactoryCompteG
|
||
persist_compte = factory_compte.ReadNumero(compte)
|
||
|
||
if persist_compte:
|
||
compte_obj = win32com.client.CastTo(
|
||
persist_compte, "IBOCompteG3"
|
||
)
|
||
compte_obj.Read()
|
||
|
||
# Assigner l'objet CompteG
|
||
fournisseur.CompteGPrinc = compte_obj
|
||
logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné")
|
||
else:
|
||
logger.warning(
|
||
f" ⚠️ Compte {compte} introuvable - utilisation défaut"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}")
|
||
|
||
# 5. Numéro fournisseur (OBLIGATOIRE - générer si vide)
|
||
if num_prop:
|
||
fournisseur.CT_Num = num_prop
|
||
logger.debug(f" ✅ CT_Num fourni: '{num_prop}'")
|
||
else:
|
||
# 🔑 CRITIQUE : Générer le numéro automatiquement
|
||
try:
|
||
# Méthode 1 : SetDefaultNumPiece (si disponible)
|
||
if hasattr(fournisseur, "SetDefaultNumPiece"):
|
||
fournisseur.SetDefaultNumPiece()
|
||
num_genere = getattr(fournisseur, "CT_Num", "")
|
||
logger.debug(f" ✅ CT_Num auto-généré: '{num_genere}'")
|
||
else:
|
||
# Méthode 2 : GetNextNumero depuis la factory
|
||
num_genere = factory_fournisseur.GetNextNumero()
|
||
if num_genere:
|
||
fournisseur.CT_Num = num_genere
|
||
logger.debug(
|
||
f" ✅ CT_Num auto (GetNextNumero): '{num_genere}'"
|
||
)
|
||
else:
|
||
# Méthode 3 : Fallback - timestamp
|
||
import time
|
||
|
||
num_genere = f"FOUR{int(time.time()) % 1000000}"
|
||
fournisseur.CT_Num = num_genere
|
||
logger.warning(f" ⚠️ CT_Num fallback: '{num_genere}'")
|
||
except Exception as e:
|
||
logger.error(f" ❌ Impossible de générer CT_Num: {e}")
|
||
raise ValueError(
|
||
"Impossible de générer le numéro fournisseur automatiquement"
|
||
)
|
||
|
||
# 6. Catégories (valeurs par défaut)
|
||
try:
|
||
if hasattr(fournisseur, "N_CatTarif"):
|
||
fournisseur.N_CatTarif = 1
|
||
if hasattr(fournisseur, "N_CatCompta"):
|
||
fournisseur.N_CatCompta = 1
|
||
if hasattr(fournisseur, "N_Period"):
|
||
fournisseur.N_Period = 1
|
||
logger.debug(" ✅ Catégories (N_*) initialisées")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Catégories: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : CHAMPS OPTIONNELS
|
||
# ========================================
|
||
logger.info("📝 Définition champs optionnels...")
|
||
|
||
# Adresse (objet IAdresse)
|
||
if any([adresse, code_postal, ville, pays]):
|
||
try:
|
||
adresse_obj = fournisseur.Adresse
|
||
|
||
if adresse:
|
||
adresse_obj.Adresse = adresse
|
||
if code_postal:
|
||
adresse_obj.CodePostal = code_postal
|
||
if ville:
|
||
adresse_obj.Ville = ville
|
||
if pays:
|
||
adresse_obj.Pays = pays
|
||
|
||
logger.debug(" ✅ Adresse définie")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Adresse: {e}")
|
||
|
||
# Télécom (objet ITelecom)
|
||
if telephone or email:
|
||
try:
|
||
telecom_obj = fournisseur.Telecom
|
||
|
||
if telephone:
|
||
telecom_obj.Telephone = telephone
|
||
if email:
|
||
telecom_obj.EMail = email
|
||
|
||
logger.debug(" ✅ Télécom défini")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Télécom: {e}")
|
||
|
||
# Identifiants fiscaux
|
||
if siret:
|
||
try:
|
||
fournisseur.CT_Siret = siret
|
||
logger.debug(f" ✅ SIRET: '{siret}'")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ SIRET: {e}")
|
||
|
||
if tva_intra:
|
||
try:
|
||
fournisseur.CT_Identifiant = tva_intra
|
||
logger.debug(f" ✅ TVA intra: '{tva_intra}'")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ TVA: {e}")
|
||
|
||
# Options par défaut
|
||
try:
|
||
if hasattr(fournisseur, "CT_Lettrage"):
|
||
fournisseur.CT_Lettrage = True
|
||
if hasattr(fournisseur, "CT_Sommeil"):
|
||
fournisseur.CT_Sommeil = False
|
||
logger.debug(" ✅ Options par défaut définies")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ Options: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : VÉRIFICATION PRÉ-WRITE
|
||
# ========================================
|
||
logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===")
|
||
|
||
num_avant_write = getattr(fournisseur, "CT_Num", "")
|
||
if not num_avant_write:
|
||
logger.error("❌ CRITIQUE: CT_Num toujours vide !")
|
||
raise ValueError("Le numéro fournisseur (CT_Num) est obligatoire")
|
||
|
||
logger.info(f"✅ CT_Num confirmé: '{num_avant_write}'")
|
||
|
||
# ========================================
|
||
# ÉTAPE 5 : ÉCRITURE EN BASE
|
||
# ========================================
|
||
logger.info("💾 Écriture du fournisseur dans Sage...")
|
||
|
||
try:
|
||
fournisseur.Write()
|
||
logger.info("✅ Write() réussi !")
|
||
|
||
except Exception as e:
|
||
error_detail = str(e)
|
||
|
||
# Récupérer l'erreur Sage détaillée
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_detail = (
|
||
f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
)
|
||
logger.error(f"❌ Erreur Sage: {error_detail}")
|
||
except:
|
||
pass
|
||
|
||
# Analyser l'erreur
|
||
if (
|
||
"doublon" in error_detail.lower()
|
||
or "existe" in error_detail.lower()
|
||
):
|
||
raise ValueError(f"Ce fournisseur existe déjà : {error_detail}")
|
||
|
||
raise RuntimeError(f"Échec Write(): {error_detail}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 6 : RELECTURE & FINALISATION
|
||
# ========================================
|
||
try:
|
||
fournisseur.Read()
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Impossible de relire: {e}")
|
||
|
||
num_final = getattr(fournisseur, "CT_Num", "")
|
||
|
||
if not num_final:
|
||
raise RuntimeError("CT_Num vide après Write()")
|
||
|
||
logger.info(f"✅✅✅ FOURNISSEUR CRÉÉ: {num_final} - {intitule} ✅✅✅")
|
||
|
||
# ========================================
|
||
# ÉTAPE 7 : CONSTRUCTION RÉPONSE
|
||
# ========================================
|
||
resultat = {
|
||
"numero": num_final,
|
||
"intitule": intitule,
|
||
"compte_collectif": compte,
|
||
"type": 1, # Fournisseur
|
||
"est_fournisseur": True,
|
||
"adresse": adresse or None,
|
||
"code_postal": code_postal or None,
|
||
"ville": ville or None,
|
||
"pays": pays or None,
|
||
"email": email or None,
|
||
"telephone": telephone or None,
|
||
"siret": siret or None,
|
||
"tva_intra": tva_intra or None,
|
||
}
|
||
|
||
# ⚠️ PAS DE REFRESH CACHE ICI
|
||
# Car lister_tous_fournisseurs() utilise FactoryFournisseur.List()
|
||
# qui lit directement depuis Sage (pas de cache)
|
||
|
||
return resultat
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur création fournisseur: {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = f"Erreur Sage: {err.Description}"
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur technique Sage: {error_message}")
|
||
|
||
def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
# ========================================
|
||
# ÉTAPE 1 : CHARGER LE FOURNISSEUR EXISTANT
|
||
# ========================================
|
||
logger.info(f"🔍 Recherche fournisseur {code}...")
|
||
|
||
factory_fournisseur = self.cial.CptaApplication.FactoryFournisseur
|
||
persist = factory_fournisseur.ReadNumero(code)
|
||
|
||
if not persist:
|
||
raise ValueError(f"Fournisseur {code} introuvable")
|
||
|
||
fournisseur = self._cast_client(persist) # ✅ Réutiliser _cast_client
|
||
if not fournisseur:
|
||
raise ValueError(f"Impossible de charger le fournisseur {code}")
|
||
|
||
logger.info(
|
||
f"✅ Fournisseur {code} trouvé: {getattr(fournisseur, 'CT_Intitule', '')}"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS
|
||
# ========================================
|
||
logger.info("📝 Mise à jour des champs...")
|
||
|
||
champs_modifies = []
|
||
|
||
# Intitulé
|
||
if "intitule" in fournisseur_data:
|
||
intitule = str(fournisseur_data["intitule"])[:69].strip()
|
||
fournisseur.CT_Intitule = intitule
|
||
champs_modifies.append(f"intitule='{intitule}'")
|
||
|
||
# Adresse
|
||
if any(
|
||
k in fournisseur_data
|
||
for k in ["adresse", "code_postal", "ville", "pays"]
|
||
):
|
||
try:
|
||
adresse_obj = fournisseur.Adresse
|
||
|
||
if "adresse" in fournisseur_data:
|
||
adresse = str(fournisseur_data["adresse"])[:35].strip()
|
||
adresse_obj.Adresse = adresse
|
||
champs_modifies.append("adresse")
|
||
|
||
if "code_postal" in fournisseur_data:
|
||
cp = str(fournisseur_data["code_postal"])[:9].strip()
|
||
adresse_obj.CodePostal = cp
|
||
champs_modifies.append("code_postal")
|
||
|
||
if "ville" in fournisseur_data:
|
||
ville = str(fournisseur_data["ville"])[:35].strip()
|
||
adresse_obj.Ville = ville
|
||
champs_modifies.append("ville")
|
||
|
||
if "pays" in fournisseur_data:
|
||
pays = str(fournisseur_data["pays"])[:35].strip()
|
||
adresse_obj.Pays = pays
|
||
champs_modifies.append("pays")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour adresse: {e}")
|
||
|
||
# Télécom
|
||
if "email" in fournisseur_data or "telephone" in fournisseur_data:
|
||
try:
|
||
telecom_obj = fournisseur.Telecom
|
||
|
||
if "email" in fournisseur_data:
|
||
email = str(fournisseur_data["email"])[:69].strip()
|
||
telecom_obj.EMail = email
|
||
champs_modifies.append("email")
|
||
|
||
if "telephone" in fournisseur_data:
|
||
tel = str(fournisseur_data["telephone"])[:21].strip()
|
||
telecom_obj.Telephone = tel
|
||
champs_modifies.append("telephone")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour télécom: {e}")
|
||
|
||
# SIRET
|
||
if "siret" in fournisseur_data:
|
||
try:
|
||
siret = str(fournisseur_data["siret"])[:14].strip()
|
||
fournisseur.CT_Siret = siret
|
||
champs_modifies.append("siret")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}")
|
||
|
||
# TVA Intracommunautaire
|
||
if "tva_intra" in fournisseur_data:
|
||
try:
|
||
tva = str(fournisseur_data["tva_intra"])[:25].strip()
|
||
fournisseur.CT_Identifiant = tva
|
||
champs_modifies.append("tva_intra")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour TVA: {e}")
|
||
|
||
if not champs_modifies:
|
||
logger.warning("⚠️ Aucun champ à modifier")
|
||
# Retourner les données actuelles via extraction directe
|
||
return {
|
||
"numero": getattr(fournisseur, "CT_Num", "").strip(),
|
||
"intitule": getattr(fournisseur, "CT_Intitule", "").strip(),
|
||
"type": 1,
|
||
"est_fournisseur": True,
|
||
}
|
||
|
||
logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS
|
||
# ========================================
|
||
logger.info("💾 Écriture des modifications...")
|
||
|
||
try:
|
||
fournisseur.Write()
|
||
logger.info("✅ Write() réussi !")
|
||
|
||
except Exception as e:
|
||
error_detail = str(e)
|
||
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_detail = (
|
||
f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
logger.error(f"❌ Erreur Write(): {error_detail}")
|
||
raise RuntimeError(f"Échec modification: {error_detail}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : RELIRE ET RETOURNER
|
||
# ========================================
|
||
fournisseur.Read()
|
||
|
||
logger.info(
|
||
f"✅✅✅ FOURNISSEUR MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅"
|
||
)
|
||
|
||
# Extraction directe (comme lire_fournisseur)
|
||
numero = getattr(fournisseur, "CT_Num", "").strip()
|
||
intitule = getattr(fournisseur, "CT_Intitule", "").strip()
|
||
|
||
data = {
|
||
"numero": numero,
|
||
"intitule": intitule,
|
||
"type": 1,
|
||
"est_fournisseur": True,
|
||
}
|
||
|
||
# Adresse
|
||
try:
|
||
adresse_obj = getattr(fournisseur, "Adresse", None)
|
||
if adresse_obj:
|
||
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
|
||
data["code_postal"] = getattr(
|
||
adresse_obj, "CodePostal", ""
|
||
).strip()
|
||
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
|
||
except:
|
||
data["adresse"] = ""
|
||
data["code_postal"] = ""
|
||
data["ville"] = ""
|
||
|
||
# Télécom
|
||
try:
|
||
telecom_obj = getattr(fournisseur, "Telecom", None)
|
||
if telecom_obj:
|
||
data["telephone"] = getattr(
|
||
telecom_obj, "Telephone", ""
|
||
).strip()
|
||
data["email"] = getattr(telecom_obj, "EMail", "").strip()
|
||
except:
|
||
data["telephone"] = ""
|
||
data["email"] = ""
|
||
|
||
return data
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur modification fournisseur: {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = f"Erreur Sage: {err.Description}"
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur technique Sage: {error_message}")
|
||
|
||
def lister_tous_clients(self, filtre=""):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
query = """
|
||
SELECT
|
||
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
|
||
CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays,
|
||
CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant,
|
||
CT_Sommeil, CT_Prospect, CT_Contact
|
||
FROM F_COMPTET
|
||
WHERE CT_Type = 0
|
||
"""
|
||
|
||
params = []
|
||
|
||
if filtre:
|
||
query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)"
|
||
params.extend([f"%{filtre}%", f"%{filtre}%"])
|
||
|
||
query += " ORDER BY CT_Intitule"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
clients = []
|
||
for row in rows:
|
||
clients.append(
|
||
{
|
||
"numero": self._safe_strip(row.CT_Num),
|
||
"intitule": self._safe_strip(row.CT_Intitule),
|
||
"type": row.CT_Type,
|
||
"qualite": self._safe_strip(row.CT_Qualite),
|
||
"adresse": self._safe_strip(row.CT_Adresse),
|
||
"ville": self._safe_strip(row.CT_Ville),
|
||
"code_postal": self._safe_strip(row.CT_CodePostal),
|
||
"pays": self._safe_strip(row.CT_Pays),
|
||
"telephone": self._safe_strip(row.CT_Telephone),
|
||
"email": self._safe_strip(row.CT_EMail),
|
||
"siret": self._safe_strip(row.CT_Siret),
|
||
"tva_intra": self._safe_strip(row.CT_Identifiant),
|
||
"est_actif": (row.CT_Sommeil == 0),
|
||
"est_prospect": (row.CT_Prospect == 1),
|
||
"contact": self._safe_strip(row.CT_Contact),
|
||
}
|
||
)
|
||
|
||
logger.info(f"✅ SQL: {len(clients)} clients")
|
||
return clients
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL clients: {e}")
|
||
raise RuntimeError(f"Erreur lecture clients: {str(e)}")
|
||
|
||
def lire_client(self, code_client):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# ✅ MÊME REQUÊTE que lister_tous_clients (colonnes existantes uniquement)
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
|
||
CT_Adresse, CT_Ville, CT_CodePostal, CT_Pays,
|
||
CT_Telephone, CT_EMail, CT_Siret, CT_Identifiant,
|
||
CT_Sommeil, CT_Prospect, CT_Contact
|
||
FROM F_COMPTET
|
||
WHERE CT_Num = ?
|
||
""",
|
||
(code_client.upper(),),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
return {
|
||
"numero": self._safe_strip(row[0]),
|
||
"intitule": self._safe_strip(row[1]),
|
||
"type": row[2],
|
||
"qualite": self._safe_strip(row[3]),
|
||
"adresse": self._safe_strip(row[4]),
|
||
"ville": self._safe_strip(row[5]),
|
||
"code_postal": self._safe_strip(row[6]),
|
||
"pays": self._safe_strip(row[7]),
|
||
"telephone": self._safe_strip(row[8]),
|
||
"email": self._safe_strip(row[9]),
|
||
"siret": self._safe_strip(row[10]),
|
||
"tva_intra": self._safe_strip(row[11]),
|
||
"est_actif": (row[12] == 0),
|
||
"est_prospect": (row[13] == 1),
|
||
"contact": self._safe_strip(row[14]),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL client {code_client}: {e}")
|
||
return None
|
||
|
||
def lister_tous_articles(self, filtre="", avec_stock=True):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : LIRE LES ARTICLES DE BASE
|
||
# ========================================
|
||
query = """
|
||
SELECT
|
||
AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch,
|
||
AR_UniteVen, FA_CodeFamille, AR_Sommeil,
|
||
AR_CodeBarre, AR_Type
|
||
FROM F_ARTICLE
|
||
WHERE 1=1
|
||
"""
|
||
|
||
params = []
|
||
|
||
if filtre:
|
||
query += " AND (AR_Ref LIKE ? OR AR_Design LIKE ?)"
|
||
params.extend([f"%{filtre}%", f"%{filtre}%"])
|
||
|
||
query += " ORDER BY AR_Ref"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
articles = []
|
||
|
||
for row in rows:
|
||
article = {
|
||
"reference": self._safe_strip(row[0]),
|
||
"designation": self._safe_strip(row[1]),
|
||
"prix_vente": float(row[2]) if row[2] is not None else 0.0,
|
||
"prix_achat": float(row[3]) if row[3] is not None else 0.0,
|
||
"unite_vente": (
|
||
str(row[4]).strip() if row[4] is not None else ""
|
||
),
|
||
"famille_code": self._safe_strip(row[5]),
|
||
"est_actif": (row[6] == 0),
|
||
"code_ean": self._safe_strip(row[7]),
|
||
"type_article": row[8] if row[8] is not None else 0,
|
||
# ✅ CORRECTION : Pas de AR_Stock dans ta base !
|
||
"stock_reel": 0.0,
|
||
"stock_mini": 0.0,
|
||
"stock_maxi": 0.0,
|
||
"stock_reserve": 0.0,
|
||
"stock_commande": 0.0,
|
||
"stock_disponible": 0.0,
|
||
}
|
||
article["code_barre"] = article["code_ean"]
|
||
articles.append(article)
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : ENRICHIR AVEC STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE)
|
||
# ========================================
|
||
if avec_stock and articles:
|
||
logger.info(
|
||
f"📦 Enrichissement stock depuis F_ARTSTOCK pour {len(articles)} articles..."
|
||
)
|
||
|
||
try:
|
||
# Créer un mapping des références
|
||
references = [
|
||
a["reference"] for a in articles if a["reference"]
|
||
]
|
||
|
||
if not references:
|
||
return articles
|
||
|
||
# Requête pour récupérer TOUS les stocks en une fois
|
||
placeholders = ",".join(["?"] * len(references))
|
||
stock_query = f"""
|
||
SELECT
|
||
AR_Ref,
|
||
SUM(ISNULL(AS_QteSto, 0)) as Stock_Total,
|
||
MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini,
|
||
MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi,
|
||
SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve,
|
||
SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande
|
||
FROM F_ARTSTOCK
|
||
WHERE AR_Ref IN ({placeholders})
|
||
GROUP BY AR_Ref
|
||
"""
|
||
|
||
cursor.execute(stock_query, references)
|
||
stock_rows = cursor.fetchall()
|
||
|
||
stock_map = {}
|
||
for stock_row in stock_rows:
|
||
ref = self._safe_strip(stock_row[0])
|
||
if ref:
|
||
stock_map[ref] = {
|
||
"stock_reel": (
|
||
float(stock_row[1]) if stock_row[1] else 0.0
|
||
),
|
||
"stock_mini": (
|
||
float(stock_row[2]) if stock_row[2] else 0.0
|
||
),
|
||
"stock_maxi": (
|
||
float(stock_row[3]) if stock_row[3] else 0.0
|
||
),
|
||
"stock_reserve": (
|
||
float(stock_row[4]) if stock_row[4] else 0.0
|
||
),
|
||
"stock_commande": (
|
||
float(stock_row[5]) if stock_row[5] else 0.0
|
||
),
|
||
}
|
||
|
||
logger.info(
|
||
f"✅ Stocks chargés pour {len(stock_map)} articles depuis F_ARTSTOCK"
|
||
)
|
||
|
||
# Enrichir les articles
|
||
for article in articles:
|
||
if article["reference"] in stock_map:
|
||
stock_data = stock_map[article["reference"]]
|
||
article.update(stock_data)
|
||
article["stock_disponible"] = (
|
||
article["stock_reel"] - article["stock_reserve"]
|
||
)
|
||
else:
|
||
# Article sans stock enregistré
|
||
article["stock_reel"] = 0.0
|
||
article["stock_mini"] = 0.0
|
||
article["stock_maxi"] = 0.0
|
||
article["stock_reserve"] = 0.0
|
||
article["stock_commande"] = 0.0
|
||
article["stock_disponible"] = 0.0
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"❌ Erreur lecture F_ARTSTOCK: {e}", exc_info=True
|
||
)
|
||
# Ne pas lever d'exception, retourner les articles sans stock
|
||
|
||
logger.info(
|
||
f"✅ SQL: {len(articles)} articles (stock={'oui' if avec_stock else 'non'})"
|
||
)
|
||
return articles
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL articles: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur lecture articles: {str(e)}")
|
||
|
||
def lire_article(self, reference):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
AR_Ref, AR_Design, AR_PrixVen, AR_PrixAch,
|
||
AR_UniteVen, FA_CodeFamille, AR_Sommeil,
|
||
AR_CodeBarre, AR_Type
|
||
FROM F_ARTICLE
|
||
WHERE AR_Ref = ?
|
||
""",
|
||
(reference.upper(),),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
article = {
|
||
"reference": self._safe_strip(row[0]),
|
||
"designation": self._safe_strip(row[1]),
|
||
"prix_vente": float(row[2]) if row[2] is not None else 0.0,
|
||
"prix_achat": float(row[3]) if row[3] is not None else 0.0,
|
||
"unite_vente": str(row[4]).strip() if row[4] is not None else "",
|
||
"famille_code": self._safe_strip(row[5]),
|
||
"est_actif": (row[6] == 0),
|
||
"code_ean": self._safe_strip(row[7]),
|
||
"code_barre": self._safe_strip(row[7]),
|
||
"type_article": row[8] if row[8] is not None else 0,
|
||
"type_article_libelle": {
|
||
0: "Article",
|
||
1: "Prestation",
|
||
2: "Divers",
|
||
}.get(row[8] if row[8] is not None else 0, "Article"),
|
||
# Champs optionnels (initialisés à vide/valeur par défaut)
|
||
"description": "",
|
||
"designation_complementaire": "",
|
||
"poids": 0.0,
|
||
"volume": 0.0,
|
||
"tva_code": "",
|
||
"date_creation": "",
|
||
"date_modification": "",
|
||
# Stock initialisé à 0 - sera mis à jour depuis F_ARTSTOCK
|
||
"stock_reel": 0.0,
|
||
"stock_mini": 0.0,
|
||
"stock_maxi": 0.0,
|
||
"stock_reserve": 0.0,
|
||
"stock_commande": 0.0,
|
||
"stock_disponible": 0.0,
|
||
}
|
||
|
||
# TVA taux (par défaut 20%)
|
||
article["tva_taux"] = 20.0
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : LIRE LE STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE)
|
||
# ========================================
|
||
logger.info(f"📦 Lecture stock depuis F_ARTSTOCK pour {reference}...")
|
||
|
||
try:
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
SUM(ISNULL(AS_QteSto, 0)) as Stock_Total,
|
||
MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini,
|
||
MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi,
|
||
SUM(ISNULL(AS_QteRes, 0)) as Stock_Reserve,
|
||
SUM(ISNULL(AS_QteCom, 0)) as Stock_Commande
|
||
FROM F_ARTSTOCK
|
||
WHERE AR_Ref = ?
|
||
GROUP BY AR_Ref
|
||
""",
|
||
(reference.upper(),),
|
||
)
|
||
|
||
stock_row = cursor.fetchone()
|
||
|
||
if stock_row:
|
||
# ✅ STOCK DEPUIS F_ARTSTOCK
|
||
article["stock_reel"] = (
|
||
float(stock_row[0]) if stock_row[0] else 0.0
|
||
)
|
||
article["stock_mini"] = (
|
||
float(stock_row[1]) if stock_row[1] else 0.0
|
||
)
|
||
article["stock_maxi"] = (
|
||
float(stock_row[2]) if stock_row[2] else 0.0
|
||
)
|
||
|
||
# Priorité aux réserves/commandes de F_ARTSTOCK si disponibles
|
||
stock_reserve_artstock = (
|
||
float(stock_row[3]) if stock_row[3] else 0.0
|
||
)
|
||
stock_commande_artstock = (
|
||
float(stock_row[4]) if stock_row[4] else 0.0
|
||
)
|
||
|
||
if stock_reserve_artstock > 0:
|
||
article["stock_reserve"] = stock_reserve_artstock
|
||
if stock_commande_artstock > 0:
|
||
article["stock_commande"] = stock_commande_artstock
|
||
|
||
article["stock_disponible"] = (
|
||
article["stock_reel"] - article["stock_reserve"]
|
||
)
|
||
|
||
logger.info(
|
||
f"✅ Stock trouvé dans F_ARTSTOCK: {article['stock_reel']} unités"
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"⚠️ Aucun stock trouvé dans F_ARTSTOCK pour {reference}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur lecture F_ARTSTOCK pour {reference}: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : ENRICHIR AVEC LIBELLÉ FAMILLE
|
||
# ========================================
|
||
if article["famille_code"]:
|
||
try:
|
||
cursor.execute(
|
||
"SELECT FA_Intitule FROM F_FAMILLE WHERE FA_CodeFamille = ?",
|
||
(article["famille_code"],),
|
||
)
|
||
famille_row = cursor.fetchone()
|
||
if famille_row:
|
||
article["famille_libelle"] = self._safe_strip(
|
||
famille_row[0]
|
||
)
|
||
else:
|
||
article["famille_libelle"] = ""
|
||
except:
|
||
article["famille_libelle"] = ""
|
||
else:
|
||
article["famille_libelle"] = ""
|
||
|
||
return article
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL article {reference}: {e}")
|
||
return None
|
||
|
||
def _lire_document_sql(self, numero: str, type_doc: int):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# ========================================
|
||
# VÉRIFIER SI DO_Domaine EXISTE
|
||
# ========================================
|
||
do_domaine_existe = False
|
||
|
||
try:
|
||
cursor.execute(
|
||
"SELECT TOP 1 DO_Domaine FROM F_DOCENTETE WHERE DO_Type = ?",
|
||
(type_doc,),
|
||
)
|
||
row_test = cursor.fetchone()
|
||
if row_test is not None:
|
||
do_domaine_existe = True
|
||
except:
|
||
do_domaine_existe = False
|
||
|
||
# ========================================
|
||
# LIRE L'ENTÊTE (avec filtre DO_Domaine si disponible)
|
||
# ========================================
|
||
if do_domaine_existe:
|
||
query = """
|
||
SELECT
|
||
DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC,
|
||
DO_Statut, DO_Tiers
|
||
FROM F_DOCENTETE
|
||
WHERE DO_Piece = ? AND DO_Type = ? AND DO_Domaine = 0
|
||
"""
|
||
else:
|
||
query = """
|
||
SELECT
|
||
DO_Piece, DO_Date, DO_Ref, DO_TotalHT, DO_TotalTTC,
|
||
DO_Statut, DO_Tiers
|
||
FROM F_DOCENTETE
|
||
WHERE DO_Piece = ? AND DO_Type = ?
|
||
"""
|
||
|
||
cursor.execute(query, (numero, type_doc))
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
# ✅ Si DO_Domaine n'existe pas, vérifier le préfixe
|
||
if not do_domaine_existe:
|
||
prefixes_vente = {
|
||
0: ["DE"],
|
||
10: ["BC"],
|
||
30: ["BL"],
|
||
50: ["AV", "AR"],
|
||
60: ["FA", "FC"],
|
||
}
|
||
|
||
prefixes_acceptes = prefixes_vente.get(type_doc, [])
|
||
est_vente = any(
|
||
numero.upper().startswith(p) for p in prefixes_acceptes
|
||
)
|
||
|
||
if not est_vente:
|
||
logger.warning(
|
||
f"Document {numero} semble être un document d'achat (préfixe non reconnu)"
|
||
)
|
||
|
||
return None
|
||
|
||
# Charger le client
|
||
client_code = self._safe_strip(row[6]) if row[6] else ""
|
||
client_intitule = ""
|
||
|
||
if client_code:
|
||
cursor.execute(
|
||
"""
|
||
SELECT CT_Intitule
|
||
FROM F_COMPTET
|
||
WHERE CT_Num = ?
|
||
""",
|
||
(client_code,),
|
||
)
|
||
|
||
client_row = cursor.fetchone()
|
||
if client_row:
|
||
client_intitule = self._safe_strip(client_row[0])
|
||
|
||
# ========================================
|
||
# LIRE LES LIGNES
|
||
# ========================================
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT,
|
||
DL_Remise01REM_Valeur, DL_Remise01REM_Type
|
||
FROM F_DOCLIGNE
|
||
WHERE DO_Piece = ? AND DO_Type = ?
|
||
ORDER BY DL_Ligne
|
||
""",
|
||
(numero, type_doc),
|
||
)
|
||
|
||
lignes = []
|
||
for ligne_row in cursor.fetchall():
|
||
ligne = {
|
||
"article_code": self._safe_strip(ligne_row[0]),
|
||
"designation": self._safe_strip(ligne_row[1]),
|
||
"quantite": float(ligne_row[2]) if ligne_row[2] else 0.0,
|
||
"prix_unitaire_ht": (
|
||
float(ligne_row[3]) if ligne_row[3] else 0.0
|
||
),
|
||
"montant_ligne_ht": (
|
||
float(ligne_row[4]) if ligne_row[4] else 0.0
|
||
),
|
||
}
|
||
|
||
# Remise (si présente)
|
||
if ligne_row[5]:
|
||
ligne["remise_pourcentage"] = float(ligne_row[5])
|
||
ligne["remise_type"] = int(ligne_row[6]) if ligne_row[6] else 0
|
||
else:
|
||
ligne["remise_pourcentage"] = 0.0
|
||
ligne["remise_type"] = 0
|
||
|
||
lignes.append(ligne)
|
||
|
||
return {
|
||
"numero": self._safe_strip(row[0]),
|
||
"reference": self._safe_strip(row[2]),
|
||
"date": str(row[1]) if row[1] else "",
|
||
"client_code": client_code,
|
||
"client_intitule": client_intitule,
|
||
"total_ht": float(row[3]) if row[3] else 0.0,
|
||
"total_ttc": float(row[4]) if row[4] else 0.0,
|
||
"statut": row[5] if row[5] is not None else 0,
|
||
"lignes": lignes,
|
||
"nb_lignes": len(lignes),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL lecture document {numero}: {e}")
|
||
return None
|
||
|
||
def _lister_documents_avec_lignes_sql(
|
||
self, type_doc: int, filtre: str = "", limit: int = None
|
||
):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# ========================================
|
||
# ÉTAPE 0 : DIAGNOSTIC - Vérifier si DO_Domaine existe
|
||
# ========================================
|
||
do_domaine_existe = False
|
||
|
||
try:
|
||
cursor.execute(
|
||
"""
|
||
SELECT TOP 1 DO_Domaine
|
||
FROM F_DOCENTETE
|
||
WHERE DO_Type = ?
|
||
""",
|
||
(type_doc,),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
if row is not None:
|
||
do_domaine_existe = True
|
||
logger.info(
|
||
f"[SQL] Colonne DO_Domaine détectée (valeur exemple: {row[0]})"
|
||
)
|
||
except Exception as e:
|
||
logger.info(f"[SQL] Colonne DO_Domaine non disponible: {e}")
|
||
do_domaine_existe = False
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : CONSTRUIRE LA REQUÊTE SELON DISPONIBILITÉ DO_Domaine
|
||
# ========================================
|
||
if do_domaine_existe:
|
||
# Version avec filtre DO_Domaine
|
||
query = """
|
||
SELECT
|
||
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
|
||
d.DO_Statut, d.DO_Tiers, c.CT_Intitule
|
||
FROM F_DOCENTETE d
|
||
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
|
||
WHERE d.DO_Type = ?
|
||
AND d.DO_Domaine = 0
|
||
"""
|
||
logger.info(f"[SQL] Requête AVEC filtre DO_Domaine = 0")
|
||
else:
|
||
# Version SANS filtre DO_Domaine (utilise heuristique)
|
||
query = """
|
||
SELECT
|
||
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
|
||
d.DO_Statut, d.DO_Tiers, c.CT_Intitule
|
||
FROM F_DOCENTETE d
|
||
LEFT JOIN F_COMPTET c ON d.DO_Tiers = c.CT_Num
|
||
WHERE d.DO_Type = ?
|
||
"""
|
||
logger.warning(
|
||
f"[SQL] Requête SANS filtre DO_Domaine (heuristique sur préfixe)"
|
||
)
|
||
|
||
params = [type_doc]
|
||
|
||
if filtre:
|
||
query += " AND (d.DO_Piece LIKE ? OR c.CT_Intitule LIKE ?)"
|
||
params.extend([f"%{filtre}%", f"%{filtre}%"])
|
||
|
||
query += " ORDER BY d.DO_Date DESC"
|
||
|
||
if limit:
|
||
query = f"SELECT TOP ({limit}) * FROM ({query}) AS subquery"
|
||
|
||
cursor.execute(query, params)
|
||
entetes = cursor.fetchall()
|
||
|
||
logger.info(
|
||
f"[SQL] {len(entetes)} documents bruts récupérés (type={type_doc})"
|
||
)
|
||
|
||
documents = []
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : FILTRER PAR HEURISTIQUE SI DO_Domaine N'EXISTE PAS
|
||
# ========================================
|
||
for entete in entetes:
|
||
numero = self._safe_strip(entete.DO_Piece)
|
||
|
||
# Si DO_Domaine n'existe pas, filtrer par préfixe du numéro
|
||
if not do_domaine_existe:
|
||
# Heuristique :
|
||
# - Vente (clients) : BC, BL, FA, AV, DE
|
||
# - Achat (fournisseurs) : DA, RA, FAF, etc.
|
||
|
||
prefixes_vente = {
|
||
0: ["DE"], # Devis
|
||
10: ["BC"], # Bon de commande
|
||
30: ["BL"], # Bon de livraison
|
||
50: ["AV", "AR"], # Avoir
|
||
60: ["FA", "FC"], # Facture
|
||
}
|
||
|
||
prefixes_acceptes = prefixes_vente.get(type_doc, [])
|
||
|
||
if prefixes_acceptes:
|
||
# Vérifier si le numéro commence par un préfixe valide
|
||
est_vente = any(
|
||
numero.upper().startswith(p) for p in prefixes_acceptes
|
||
)
|
||
|
||
if not est_vente:
|
||
logger.debug(
|
||
f"[SQL] Document {numero} exclu (préfixe achat)"
|
||
)
|
||
continue
|
||
|
||
# Créer l'objet document de base
|
||
doc = {
|
||
"numero": numero,
|
||
"reference": self._safe_strip(entete.DO_Ref),
|
||
"date": str(entete.DO_Date) if entete.DO_Date else "",
|
||
"client_code": self._safe_strip(entete.DO_Tiers),
|
||
"client_intitule": self._safe_strip(entete.CT_Intitule),
|
||
"total_ht": (
|
||
float(entete.DO_TotalHT) if entete.DO_TotalHT else 0.0
|
||
),
|
||
"total_ttc": (
|
||
float(entete.DO_TotalTTC) if entete.DO_TotalTTC else 0.0
|
||
),
|
||
"statut": (
|
||
entete.DO_Statut if entete.DO_Statut is not None else 0
|
||
),
|
||
"lignes": [],
|
||
}
|
||
|
||
# ========================================
|
||
# CHARGER LES LIGNES
|
||
# ========================================
|
||
try:
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
AR_Ref, DL_Design, DL_Qte, DL_PrixUnitaire, DL_MontantHT,
|
||
DL_Remise01REM_Valeur, DL_Remise01REM_Type
|
||
FROM F_DOCLIGNE
|
||
WHERE DO_Piece = ? AND DO_Type = ?
|
||
ORDER BY DL_Ligne
|
||
""",
|
||
(numero, type_doc),
|
||
)
|
||
|
||
lignes_rows = cursor.fetchall()
|
||
|
||
for ligne_row in lignes_rows:
|
||
ligne = {
|
||
"article_code": self._safe_strip(ligne_row.AR_Ref),
|
||
"designation": self._safe_strip(ligne_row.DL_Design),
|
||
"quantite": (
|
||
float(ligne_row.DL_Qte) if ligne_row.DL_Qte else 0.0
|
||
),
|
||
"prix_unitaire_ht": (
|
||
float(ligne_row.DL_PrixUnitaire)
|
||
if ligne_row.DL_PrixUnitaire
|
||
else 0.0
|
||
),
|
||
"montant_ligne_ht": (
|
||
float(ligne_row.DL_MontantHT)
|
||
if ligne_row.DL_MontantHT
|
||
else 0.0
|
||
),
|
||
}
|
||
|
||
# Remise (si présente)
|
||
if ligne_row.DL_Remise01REM_Valeur:
|
||
ligne["remise_pourcentage"] = float(
|
||
ligne_row.DL_Remise01REM_Valeur
|
||
)
|
||
ligne["remise_type"] = (
|
||
int(ligne_row.DL_Remise01REM_Type)
|
||
if ligne_row.DL_Remise01REM_Type
|
||
else 0
|
||
)
|
||
else:
|
||
ligne["remise_pourcentage"] = 0.0
|
||
ligne["remise_type"] = 0
|
||
|
||
doc["lignes"].append(ligne)
|
||
|
||
except Exception as e:
|
||
logger.warning(f"Erreur chargement lignes pour {numero}: {e}")
|
||
|
||
# Ajouter le nombre de lignes
|
||
doc["nb_lignes"] = len(doc["lignes"])
|
||
|
||
documents.append(doc)
|
||
|
||
methode = "DO_Domaine" if do_domaine_existe else "heuristique prefixe"
|
||
logger.info(
|
||
f"SQL: {len(documents)} documents (type={type_doc}, filtre={methode})"
|
||
)
|
||
return documents
|
||
|
||
except Exception as e:
|
||
logger.error(f"Erreur SQL listage documents avec lignes: {e}")
|
||
return []
|
||
|
||
def lister_tous_devis_cache(self, filtre=""):
|
||
return self._lister_documents_avec_lignes_sql(type_doc=0, filtre=filtre)
|
||
|
||
def lire_devis_cache(self, numero):
|
||
return self._lire_document_sql(numero, type_doc=0)
|
||
|
||
def lister_toutes_commandes_cache(self, filtre=""):
|
||
return self._lister_documents_avec_lignes_sql(type_doc=1, filtre=filtre)
|
||
|
||
def lire_commande_cache(self, numero):
|
||
return self._lire_document_sql(numero, type_doc=1)
|
||
|
||
def lister_toutes_factures_cache(self, filtre=""):
|
||
return self._lister_documents_avec_lignes_sql(type_doc=6, filtre=filtre)
|
||
|
||
def lire_facture_cache(self, numero):
|
||
return self._lire_document_sql(numero, type_doc=6)
|
||
|
||
def lister_tous_fournisseurs_cache(self, filtre=""):
|
||
return self.lister_tous_fournisseurs()
|
||
|
||
def lire_fournisseur_cache(self, code):
|
||
return self.lire_fournisseur()
|
||
|
||
def lister_toutes_livraisons_cache(self, filtre=""):
|
||
return self._lister_documents_avec_lignes_sql(type_doc=3, filtre=filtre)
|
||
|
||
def lire_livraison_cache(self, numero):
|
||
return self._lire_document_sql(numero, type_doc=3)
|
||
|
||
def lister_tous_avoirs_cache(self, filtre=""):
|
||
return self._lister_documents_avec_lignes_sql(type_doc=5, filtre=filtre)
|
||
|
||
def lire_avoir_cache(self, numero):
|
||
return self._lire_document_sql(numero, type_doc=5)
|
||
|
||
# =========================================================================
|
||
# CAST HELPERS
|
||
# =========================================================================
|
||
|
||
def _cast_client(self, persist_obj):
|
||
try:
|
||
obj = win32com.client.CastTo(persist_obj, "IBOClient3")
|
||
obj.Read()
|
||
return obj
|
||
except Exception as e:
|
||
logger.debug(f"❌ _cast_client échoue: {e}") # ✅ AJOUTER CE LOG
|
||
return None
|
||
|
||
def _cast_article(self, persist_obj):
|
||
try:
|
||
obj = win32com.client.CastTo(persist_obj, "IBOArticle3")
|
||
obj.Read()
|
||
return obj
|
||
except:
|
||
return None
|
||
|
||
def _extraire_client(self, client_obj):
|
||
try:
|
||
# === 1. CHAMPS OBLIGATOIRES ===
|
||
try:
|
||
numero = getattr(client_obj, "CT_Num", "").strip()
|
||
if not numero:
|
||
logger.debug("⚠️ Objet sans CT_Num, skip")
|
||
return None
|
||
except Exception as e:
|
||
logger.debug(f"❌ Erreur lecture CT_Num: {e}")
|
||
return None
|
||
|
||
try:
|
||
intitule = getattr(client_obj, "CT_Intitule", "").strip()
|
||
if not intitule:
|
||
logger.debug(f"⚠️ {numero} sans CT_Intitule")
|
||
except Exception as e:
|
||
logger.debug(f"⚠️ Erreur CT_Intitule sur {numero}: {e}")
|
||
intitule = ""
|
||
|
||
# === 2. CONSTRUCTION OBJET DE BASE ===
|
||
data = {
|
||
"numero": numero,
|
||
"intitule": intitule,
|
||
}
|
||
|
||
# === 3. TYPE DE TIERS (CLIENT/PROSPECT/FOURNISSEUR) ===
|
||
# CT_Qualite : 0=Client, 1=Fournisseur, 2=Client+Fournisseur, 3=Salarié, 4=Prospect
|
||
try:
|
||
qualite_code = getattr(client_obj, "CT_Qualite", None)
|
||
|
||
# Mapper les codes vers des libellés
|
||
qualite_map = {
|
||
0: "CLI", # Client
|
||
1: "FOU", # Fournisseur
|
||
2: "CLIFOU", # Client + Fournisseur
|
||
3: "SAL", # Salarié
|
||
4: "PRO", # Prospect
|
||
}
|
||
|
||
data["qualite"] = qualite_map.get(qualite_code, "CLI")
|
||
data["est_fournisseur"] = qualite_code in [1, 2]
|
||
|
||
except:
|
||
data["qualite"] = "CLI"
|
||
data["est_fournisseur"] = False
|
||
|
||
# CT_Prospect : 0=Non, 1=Oui
|
||
try:
|
||
data["est_prospect"] = getattr(client_obj, "CT_Prospect", 0) == 1
|
||
except:
|
||
data["est_prospect"] = False
|
||
|
||
# Déterminer le type_tiers principal
|
||
if data["est_prospect"]:
|
||
data["type_tiers"] = "prospect"
|
||
elif data["est_fournisseur"] and data["qualite"] != "CLIFOU":
|
||
data["type_tiers"] = "fournisseur"
|
||
elif data["qualite"] == "CLIFOU":
|
||
data["type_tiers"] = "client_fournisseur"
|
||
else:
|
||
data["type_tiers"] = "client"
|
||
|
||
# === 4. STATUT (ACTIF / SOMMEIL) ===
|
||
try:
|
||
sommeil = getattr(client_obj, "CT_Sommeil", 0)
|
||
data["est_actif"] = sommeil == 0
|
||
data["est_en_sommeil"] = sommeil == 1
|
||
except:
|
||
data["est_actif"] = True
|
||
data["est_en_sommeil"] = False
|
||
|
||
# === 5. TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) ===
|
||
try:
|
||
forme_juridique = getattr(client_obj, "CT_FormeJuridique", "").strip()
|
||
data["forme_juridique"] = forme_juridique
|
||
data["est_entreprise"] = bool(forme_juridique)
|
||
data["est_particulier"] = not bool(forme_juridique)
|
||
except:
|
||
data["forme_juridique"] = ""
|
||
data["est_entreprise"] = False
|
||
data["est_particulier"] = True
|
||
|
||
# === 6. IDENTITÉ PERSONNE PHYSIQUE (SI PARTICULIER) ===
|
||
try:
|
||
data["civilite"] = getattr(client_obj, "CT_Civilite", "").strip()
|
||
except:
|
||
data["civilite"] = ""
|
||
|
||
try:
|
||
data["nom"] = getattr(client_obj, "CT_Nom", "").strip()
|
||
except:
|
||
data["nom"] = ""
|
||
|
||
try:
|
||
data["prenom"] = getattr(client_obj, "CT_Prenom", "").strip()
|
||
except:
|
||
data["prenom"] = ""
|
||
|
||
# Nom complet formaté (pour particuliers)
|
||
if data.get("nom") or data.get("prenom"):
|
||
parts = []
|
||
if data.get("civilite"):
|
||
parts.append(data["civilite"])
|
||
if data.get("prenom"):
|
||
parts.append(data["prenom"])
|
||
if data.get("nom"):
|
||
parts.append(data["nom"])
|
||
data["nom_complet"] = " ".join(parts)
|
||
else:
|
||
data["nom_complet"] = ""
|
||
|
||
# === 7. CONTACT PRINCIPAL ===
|
||
try:
|
||
data["contact"] = getattr(client_obj, "CT_Contact", "").strip()
|
||
except:
|
||
data["contact"] = ""
|
||
|
||
# === 8. ADRESSE COMPLÈTE ===
|
||
try:
|
||
adresse_obj = getattr(client_obj, "Adresse", None)
|
||
if adresse_obj:
|
||
try:
|
||
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
|
||
except:
|
||
data["adresse"] = ""
|
||
|
||
try:
|
||
data["complement"] = getattr(
|
||
adresse_obj, "Complement", ""
|
||
).strip()
|
||
except:
|
||
data["complement"] = ""
|
||
|
||
try:
|
||
data["code_postal"] = getattr(
|
||
adresse_obj, "CodePostal", ""
|
||
).strip()
|
||
except:
|
||
data["code_postal"] = ""
|
||
|
||
try:
|
||
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
|
||
except:
|
||
data["ville"] = ""
|
||
|
||
try:
|
||
data["region"] = getattr(adresse_obj, "Region", "").strip()
|
||
except:
|
||
data["region"] = ""
|
||
|
||
try:
|
||
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
|
||
except:
|
||
data["pays"] = ""
|
||
else:
|
||
data["adresse"] = ""
|
||
data["complement"] = ""
|
||
data["code_postal"] = ""
|
||
data["ville"] = ""
|
||
data["region"] = ""
|
||
data["pays"] = ""
|
||
except Exception as e:
|
||
logger.debug(f"⚠️ Erreur adresse sur {numero}: {e}")
|
||
data["adresse"] = ""
|
||
data["complement"] = ""
|
||
data["code_postal"] = ""
|
||
data["ville"] = ""
|
||
data["region"] = ""
|
||
data["pays"] = ""
|
||
|
||
# === 9. TÉLÉCOMMUNICATIONS (DISTINCTION FIXE/MOBILE) ===
|
||
try:
|
||
telecom = getattr(client_obj, "Telecom", None)
|
||
if telecom:
|
||
# Téléphone FIXE
|
||
try:
|
||
data["telephone"] = getattr(telecom, "Telephone", "").strip()
|
||
except:
|
||
data["telephone"] = ""
|
||
|
||
# Téléphone MOBILE
|
||
try:
|
||
data["portable"] = getattr(telecom, "Portable", "").strip()
|
||
except:
|
||
data["portable"] = ""
|
||
|
||
# FAX
|
||
try:
|
||
data["telecopie"] = getattr(telecom, "Telecopie", "").strip()
|
||
except:
|
||
data["telecopie"] = ""
|
||
|
||
# EMAIL
|
||
try:
|
||
data["email"] = getattr(telecom, "EMail", "").strip()
|
||
except:
|
||
data["email"] = ""
|
||
|
||
# SITE WEB
|
||
try:
|
||
site = (
|
||
getattr(telecom, "Site", None)
|
||
or getattr(telecom, "Web", None)
|
||
or getattr(telecom, "SiteWeb", "")
|
||
)
|
||
data["site_web"] = str(site).strip() if site else ""
|
||
except:
|
||
data["site_web"] = ""
|
||
else:
|
||
data["telephone"] = ""
|
||
data["portable"] = ""
|
||
data["telecopie"] = ""
|
||
data["email"] = ""
|
||
data["site_web"] = ""
|
||
except Exception as e:
|
||
logger.debug(f"⚠️ Erreur telecom sur {numero}: {e}")
|
||
data["telephone"] = ""
|
||
data["portable"] = ""
|
||
data["telecopie"] = ""
|
||
data["email"] = ""
|
||
data["site_web"] = ""
|
||
|
||
# === 10. INFORMATIONS JURIDIQUES (ENTREPRISES) ===
|
||
try:
|
||
data["siret"] = getattr(client_obj, "CT_Siret", "").strip()
|
||
except:
|
||
data["siret"] = ""
|
||
|
||
try:
|
||
data["siren"] = getattr(client_obj, "CT_Siren", "").strip()
|
||
except:
|
||
data["siren"] = ""
|
||
|
||
try:
|
||
data["tva_intra"] = getattr(client_obj, "CT_Identifiant", "").strip()
|
||
except:
|
||
data["tva_intra"] = ""
|
||
|
||
try:
|
||
data["code_naf"] = (
|
||
getattr(client_obj, "CT_CodeNAF", "").strip()
|
||
or getattr(client_obj, "CT_APE", "").strip()
|
||
)
|
||
except:
|
||
data["code_naf"] = ""
|
||
|
||
# === 11. INFORMATIONS COMMERCIALES ===
|
||
try:
|
||
data["secteur"] = getattr(client_obj, "CT_Secteur", "").strip()
|
||
except:
|
||
data["secteur"] = ""
|
||
|
||
try:
|
||
effectif = getattr(client_obj, "CT_Effectif", None)
|
||
data["effectif"] = int(effectif) if effectif is not None else None
|
||
except:
|
||
data["effectif"] = None
|
||
|
||
try:
|
||
ca = getattr(client_obj, "CT_ChiffreAffaire", None)
|
||
data["ca_annuel"] = float(ca) if ca is not None else None
|
||
except:
|
||
data["ca_annuel"] = None
|
||
|
||
# Commercial rattaché
|
||
try:
|
||
data["commercial_code"] = getattr(client_obj, "CO_No", "").strip()
|
||
except:
|
||
try:
|
||
data["commercial_code"] = getattr(
|
||
client_obj, "CT_Commercial", ""
|
||
).strip()
|
||
except:
|
||
data["commercial_code"] = ""
|
||
|
||
if data.get("commercial_code"):
|
||
try:
|
||
commercial_obj = getattr(client_obj, "Commercial", None)
|
||
if commercial_obj:
|
||
commercial_obj.Read()
|
||
data["commercial_nom"] = getattr(
|
||
commercial_obj, "CO_Nom", ""
|
||
).strip()
|
||
else:
|
||
data["commercial_nom"] = ""
|
||
except:
|
||
data["commercial_nom"] = ""
|
||
else:
|
||
data["commercial_nom"] = ""
|
||
|
||
# === 12. CATÉGORIES ===
|
||
try:
|
||
data["categorie_tarifaire"] = getattr(client_obj, "N_CatTarif", None)
|
||
except:
|
||
data["categorie_tarifaire"] = None
|
||
|
||
try:
|
||
data["categorie_comptable"] = getattr(client_obj, "N_CatCompta", None)
|
||
except:
|
||
data["categorie_comptable"] = None
|
||
|
||
# === 13. INFORMATIONS FINANCIÈRES ===
|
||
try:
|
||
data["encours_autorise"] = float(getattr(client_obj, "CT_Encours", 0.0))
|
||
except:
|
||
data["encours_autorise"] = 0.0
|
||
|
||
try:
|
||
data["assurance_credit"] = float(
|
||
getattr(client_obj, "CT_Assurance", 0.0)
|
||
)
|
||
except:
|
||
data["assurance_credit"] = 0.0
|
||
|
||
try:
|
||
data["compte_general"] = getattr(client_obj, "CG_Num", "").strip()
|
||
except:
|
||
data["compte_general"] = ""
|
||
|
||
# === 14. DATES ===
|
||
try:
|
||
date_creation = getattr(client_obj, "CT_DateCreate", None)
|
||
data["date_creation"] = str(date_creation) if date_creation else ""
|
||
except:
|
||
data["date_creation"] = ""
|
||
|
||
try:
|
||
date_modif = getattr(client_obj, "CT_DateModif", None)
|
||
data["date_modification"] = str(date_modif) if date_modif else ""
|
||
except:
|
||
data["date_modification"] = ""
|
||
|
||
return data
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ ERREUR GLOBALE _extraire_client: {e}", exc_info=True)
|
||
return None
|
||
|
||
def _extraire_article(self, article_obj):
|
||
try:
|
||
data = {
|
||
# === IDENTIFICATION ===
|
||
"reference": getattr(article_obj, "AR_Ref", "").strip(),
|
||
"designation": getattr(article_obj, "AR_Design", "").strip(),
|
||
}
|
||
|
||
# === CODE EAN / CODE-BARRES ===
|
||
data["code_ean"] = ""
|
||
data["code_barre"] = ""
|
||
|
||
try:
|
||
# Essayer AR_CodeBarre (champ principal)
|
||
code_barre = getattr(article_obj, "AR_CodeBarre", "").strip()
|
||
if code_barre:
|
||
data["code_ean"] = code_barre
|
||
data["code_barre"] = code_barre
|
||
|
||
# Sinon essayer AR_CodeBarre1
|
||
if not data["code_ean"]:
|
||
code_barre1 = getattr(article_obj, "AR_CodeBarre1", "").strip()
|
||
if code_barre1:
|
||
data["code_ean"] = code_barre1
|
||
data["code_barre"] = code_barre1
|
||
except:
|
||
pass
|
||
|
||
# === PRIX ===
|
||
try:
|
||
data["prix_vente"] = float(getattr(article_obj, "AR_PrixVen", 0.0))
|
||
except:
|
||
data["prix_vente"] = 0.0
|
||
|
||
try:
|
||
data["prix_achat"] = float(getattr(article_obj, "AR_PrixAch", 0.0))
|
||
except:
|
||
data["prix_achat"] = 0.0
|
||
|
||
try:
|
||
data["prix_revient"] = float(
|
||
getattr(article_obj, "AR_PrixRevient", 0.0)
|
||
)
|
||
except:
|
||
data["prix_revient"] = 0.0
|
||
|
||
# === STOCK ===
|
||
try:
|
||
data["stock_reel"] = float(getattr(article_obj, "AR_Stock", 0.0))
|
||
except:
|
||
data["stock_reel"] = 0.0
|
||
|
||
try:
|
||
data["stock_mini"] = float(getattr(article_obj, "AR_StockMini", 0.0))
|
||
except:
|
||
data["stock_mini"] = 0.0
|
||
|
||
try:
|
||
data["stock_maxi"] = float(getattr(article_obj, "AR_StockMaxi", 0.0))
|
||
except:
|
||
data["stock_maxi"] = 0.0
|
||
|
||
# Stock réservé (en commande client)
|
||
try:
|
||
data["stock_reserve"] = float(getattr(article_obj, "AR_QteCom", 0.0))
|
||
except:
|
||
data["stock_reserve"] = 0.0
|
||
|
||
# Stock en commande fournisseur
|
||
try:
|
||
data["stock_commande"] = float(
|
||
getattr(article_obj, "AR_QteComFou", 0.0)
|
||
)
|
||
except:
|
||
data["stock_commande"] = 0.0
|
||
|
||
# Stock disponible (réel - réservé)
|
||
try:
|
||
data["stock_disponible"] = data["stock_reel"] - data["stock_reserve"]
|
||
except:
|
||
data["stock_disponible"] = data["stock_reel"]
|
||
|
||
# === DESCRIPTIONS ===
|
||
# Commentaire / Description détaillée
|
||
try:
|
||
commentaire = getattr(article_obj, "AR_Commentaire", "").strip()
|
||
data["description"] = commentaire
|
||
except:
|
||
data["description"] = ""
|
||
|
||
# Désignation complémentaire
|
||
try:
|
||
design2 = getattr(article_obj, "AR_Design2", "").strip()
|
||
data["designation_complementaire"] = design2
|
||
except:
|
||
data["designation_complementaire"] = ""
|
||
|
||
# === CLASSIFICATION ===
|
||
# Type d'article (0=Article, 1=Prestation, 2=Divers)
|
||
try:
|
||
type_art = getattr(article_obj, "AR_Type", 0)
|
||
data["type_article"] = type_art
|
||
data["type_article_libelle"] = {
|
||
0: "Article",
|
||
1: "Prestation",
|
||
2: "Divers",
|
||
}.get(type_art, "Inconnu")
|
||
except:
|
||
data["type_article"] = 0
|
||
data["type_article_libelle"] = "Article"
|
||
|
||
# Famille
|
||
try:
|
||
famille_code = getattr(article_obj, "FA_CodeFamille", "").strip()
|
||
data["famille_code"] = famille_code
|
||
|
||
# Charger le libellé de la famille si disponible
|
||
if famille_code:
|
||
try:
|
||
famille_obj = getattr(article_obj, "Famille", None)
|
||
if famille_obj:
|
||
famille_obj.Read()
|
||
data["famille_libelle"] = getattr(
|
||
famille_obj, "FA_Intitule", ""
|
||
).strip()
|
||
else:
|
||
data["famille_libelle"] = ""
|
||
except:
|
||
data["famille_libelle"] = ""
|
||
else:
|
||
data["famille_libelle"] = ""
|
||
except:
|
||
data["famille_code"] = ""
|
||
data["famille_libelle"] = ""
|
||
|
||
# === FOURNISSEUR PRINCIPAL ===
|
||
try:
|
||
fournisseur_code = getattr(article_obj, "CT_Num", "").strip()
|
||
data["fournisseur_principal"] = fournisseur_code
|
||
|
||
# Charger le nom du fournisseur si disponible
|
||
if fournisseur_code:
|
||
try:
|
||
fourn_obj = getattr(article_obj, "Fournisseur", None)
|
||
if fourn_obj:
|
||
fourn_obj.Read()
|
||
data["fournisseur_nom"] = getattr(
|
||
fourn_obj, "CT_Intitule", ""
|
||
).strip()
|
||
else:
|
||
data["fournisseur_nom"] = ""
|
||
except:
|
||
data["fournisseur_nom"] = ""
|
||
else:
|
||
data["fournisseur_nom"] = ""
|
||
except:
|
||
data["fournisseur_principal"] = ""
|
||
data["fournisseur_nom"] = ""
|
||
|
||
# === UNITÉS ===
|
||
try:
|
||
data["unite_vente"] = getattr(article_obj, "AR_UniteVen", "").strip()
|
||
except:
|
||
data["unite_vente"] = ""
|
||
|
||
try:
|
||
data["unite_achat"] = getattr(article_obj, "AR_UniteAch", "").strip()
|
||
except:
|
||
data["unite_achat"] = ""
|
||
|
||
# === CARACTÉRISTIQUES PHYSIQUES ===
|
||
try:
|
||
data["poids"] = float(getattr(article_obj, "AR_Poids", 0.0))
|
||
except:
|
||
data["poids"] = 0.0
|
||
|
||
try:
|
||
data["volume"] = float(getattr(article_obj, "AR_Volume", 0.0))
|
||
except:
|
||
data["volume"] = 0.0
|
||
|
||
# === STATUT ===
|
||
try:
|
||
sommeil = getattr(article_obj, "AR_Sommeil", 0)
|
||
data["est_actif"] = sommeil == 0
|
||
data["en_sommeil"] = sommeil == 1
|
||
except:
|
||
data["est_actif"] = True
|
||
data["en_sommeil"] = False
|
||
|
||
# === TVA ===
|
||
try:
|
||
tva_code = getattr(article_obj, "TA_Code", "").strip()
|
||
data["tva_code"] = tva_code
|
||
|
||
# Essayer de charger le taux
|
||
try:
|
||
tva_obj = getattr(article_obj, "Taxe1", None)
|
||
if tva_obj:
|
||
tva_obj.Read()
|
||
data["tva_taux"] = float(getattr(tva_obj, "TA_Taux", 20.0))
|
||
else:
|
||
data["tva_taux"] = 20.0
|
||
except:
|
||
data["tva_taux"] = 20.0
|
||
except:
|
||
data["tva_code"] = ""
|
||
data["tva_taux"] = 20.0
|
||
|
||
# === DATES ===
|
||
try:
|
||
date_creation = getattr(article_obj, "AR_DateCreate", None)
|
||
data["date_creation"] = str(date_creation) if date_creation else ""
|
||
except:
|
||
data["date_creation"] = ""
|
||
|
||
try:
|
||
date_modif = getattr(article_obj, "AR_DateModif", None)
|
||
data["date_modification"] = str(date_modif) if date_modif else ""
|
||
except:
|
||
data["date_modification"] = ""
|
||
|
||
return data
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur extraction article: {e}", exc_info=True)
|
||
# Retourner structure minimale en cas d'erreur
|
||
return {
|
||
"reference": getattr(article_obj, "AR_Ref", "").strip(),
|
||
"designation": getattr(article_obj, "AR_Design", "").strip(),
|
||
"prix_vente": 0.0,
|
||
"stock_reel": 0.0,
|
||
"code_ean": "",
|
||
"description": "",
|
||
"designation_complementaire": "",
|
||
"prix_achat": 0.0,
|
||
"prix_revient": 0.0,
|
||
"stock_mini": 0.0,
|
||
"stock_maxi": 0.0,
|
||
"stock_reserve": 0.0,
|
||
"stock_commande": 0.0,
|
||
"stock_disponible": 0.0,
|
||
"code_barre": "",
|
||
"type_article": 0,
|
||
"type_article_libelle": "Article",
|
||
"famille_code": "",
|
||
"famille_libelle": "",
|
||
"fournisseur_principal": "",
|
||
"fournisseur_nom": "",
|
||
"unite_vente": "",
|
||
"unite_achat": "",
|
||
"poids": 0.0,
|
||
"volume": 0.0,
|
||
"est_actif": True,
|
||
"en_sommeil": False,
|
||
"tva_code": "",
|
||
"tva_taux": 20.0,
|
||
"date_creation": "",
|
||
"date_modification": "",
|
||
}
|
||
|
||
def _extraire_fournisseur_enrichi(self, fourn_obj):
|
||
try:
|
||
# === IDENTIFICATION ===
|
||
numero = getattr(fourn_obj, "CT_Num", "").strip()
|
||
if not numero:
|
||
return None
|
||
|
||
intitule = getattr(fourn_obj, "CT_Intitule", "").strip()
|
||
|
||
data = {
|
||
"numero": numero,
|
||
"intitule": intitule,
|
||
"type": 1, # Fournisseur
|
||
"est_fournisseur": True,
|
||
}
|
||
|
||
# === STATUT ===
|
||
try:
|
||
sommeil = getattr(fourn_obj, "CT_Sommeil", 0)
|
||
data["est_actif"] = sommeil == 0
|
||
data["en_sommeil"] = sommeil == 1
|
||
except:
|
||
data["est_actif"] = True
|
||
data["en_sommeil"] = False
|
||
|
||
# === ADRESSE PRINCIPALE ===
|
||
try:
|
||
adresse_obj = getattr(fourn_obj, "Adresse", None)
|
||
if adresse_obj:
|
||
data["adresse"] = getattr(adresse_obj, "Adresse", "").strip()
|
||
data["complement"] = getattr(adresse_obj, "Complement", "").strip()
|
||
data["code_postal"] = getattr(adresse_obj, "CodePostal", "").strip()
|
||
data["ville"] = getattr(adresse_obj, "Ville", "").strip()
|
||
data["region"] = getattr(adresse_obj, "Region", "").strip()
|
||
data["pays"] = getattr(adresse_obj, "Pays", "").strip()
|
||
|
||
# Adresse formatée complète
|
||
parties_adresse = []
|
||
if data["adresse"]:
|
||
parties_adresse.append(data["adresse"])
|
||
if data["complement"]:
|
||
parties_adresse.append(data["complement"])
|
||
if data["code_postal"] or data["ville"]:
|
||
ville_cp = f"{data['code_postal']} {data['ville']}".strip()
|
||
if ville_cp:
|
||
parties_adresse.append(ville_cp)
|
||
if data["pays"]:
|
||
parties_adresse.append(data["pays"])
|
||
|
||
data["adresse_complete"] = ", ".join(parties_adresse)
|
||
else:
|
||
data["adresse"] = ""
|
||
data["complement"] = ""
|
||
data["code_postal"] = ""
|
||
data["ville"] = ""
|
||
data["region"] = ""
|
||
data["pays"] = ""
|
||
data["adresse_complete"] = ""
|
||
except Exception as e:
|
||
logger.debug(f"⚠️ Erreur adresse fournisseur {numero}: {e}")
|
||
data["adresse"] = ""
|
||
data["complement"] = ""
|
||
data["code_postal"] = ""
|
||
data["ville"] = ""
|
||
data["region"] = ""
|
||
data["pays"] = ""
|
||
data["adresse_complete"] = ""
|
||
|
||
# === TÉLÉCOMMUNICATIONS ===
|
||
try:
|
||
telecom_obj = getattr(fourn_obj, "Telecom", None)
|
||
if telecom_obj:
|
||
data["telephone"] = getattr(telecom_obj, "Telephone", "").strip()
|
||
data["portable"] = getattr(telecom_obj, "Portable", "").strip()
|
||
data["telecopie"] = getattr(telecom_obj, "Telecopie", "").strip()
|
||
data["email"] = getattr(telecom_obj, "EMail", "").strip()
|
||
|
||
# Site web
|
||
try:
|
||
site = (
|
||
getattr(telecom_obj, "Site", None)
|
||
or getattr(telecom_obj, "Web", None)
|
||
or getattr(telecom_obj, "SiteWeb", "")
|
||
)
|
||
data["site_web"] = str(site).strip() if site else ""
|
||
except:
|
||
data["site_web"] = ""
|
||
else:
|
||
data["telephone"] = ""
|
||
data["portable"] = ""
|
||
data["telecopie"] = ""
|
||
data["email"] = ""
|
||
data["site_web"] = ""
|
||
except Exception as e:
|
||
logger.debug(f"⚠️ Erreur telecom fournisseur {numero}: {e}")
|
||
data["telephone"] = ""
|
||
data["portable"] = ""
|
||
data["telecopie"] = ""
|
||
data["email"] = ""
|
||
data["site_web"] = ""
|
||
|
||
# === INFORMATIONS FISCALES ===
|
||
# SIRET
|
||
try:
|
||
data["siret"] = getattr(fourn_obj, "CT_Siret", "").strip()
|
||
except:
|
||
data["siret"] = ""
|
||
|
||
# SIREN (extrait du SIRET si disponible)
|
||
try:
|
||
if data["siret"] and len(data["siret"]) >= 9:
|
||
data["siren"] = data["siret"][:9]
|
||
else:
|
||
data["siren"] = getattr(fourn_obj, "CT_Siren", "").strip()
|
||
except:
|
||
data["siren"] = ""
|
||
|
||
# TVA Intracommunautaire
|
||
try:
|
||
data["tva_intra"] = getattr(fourn_obj, "CT_Identifiant", "").strip()
|
||
except:
|
||
data["tva_intra"] = ""
|
||
|
||
# Code NAF/APE
|
||
try:
|
||
data["code_naf"] = (
|
||
getattr(fourn_obj, "CT_CodeNAF", "").strip()
|
||
or getattr(fourn_obj, "CT_APE", "").strip()
|
||
)
|
||
except:
|
||
data["code_naf"] = ""
|
||
|
||
# Forme juridique
|
||
try:
|
||
data["forme_juridique"] = getattr(
|
||
fourn_obj, "CT_FormeJuridique", ""
|
||
).strip()
|
||
except:
|
||
data["forme_juridique"] = ""
|
||
|
||
# === CATÉGORIES ===
|
||
# Catégorie tarifaire
|
||
try:
|
||
cat_tarif = getattr(fourn_obj, "N_CatTarif", None)
|
||
data["categorie_tarifaire"] = (
|
||
int(cat_tarif) if cat_tarif is not None else None
|
||
)
|
||
except:
|
||
data["categorie_tarifaire"] = None
|
||
|
||
# Catégorie comptable
|
||
try:
|
||
cat_compta = getattr(fourn_obj, "N_CatCompta", None)
|
||
data["categorie_comptable"] = (
|
||
int(cat_compta) if cat_compta is not None else None
|
||
)
|
||
except:
|
||
data["categorie_comptable"] = None
|
||
|
||
# === CONDITIONS DE RÈGLEMENT ===
|
||
try:
|
||
cond_regl = getattr(fourn_obj, "CT_CondRegl", "").strip()
|
||
data["conditions_reglement_code"] = cond_regl
|
||
|
||
# Charger le libellé si disponible
|
||
if cond_regl:
|
||
try:
|
||
# Essayer de charger l'objet ConditionReglement
|
||
cond_obj = getattr(fourn_obj, "ConditionReglement", None)
|
||
if cond_obj:
|
||
cond_obj.Read()
|
||
data["conditions_reglement_libelle"] = getattr(
|
||
cond_obj, "C_Intitule", ""
|
||
).strip()
|
||
else:
|
||
data["conditions_reglement_libelle"] = ""
|
||
except:
|
||
data["conditions_reglement_libelle"] = ""
|
||
else:
|
||
data["conditions_reglement_libelle"] = ""
|
||
except:
|
||
data["conditions_reglement_code"] = ""
|
||
data["conditions_reglement_libelle"] = ""
|
||
|
||
# Mode de règlement
|
||
try:
|
||
mode_regl = getattr(fourn_obj, "CT_ModeRegl", "").strip()
|
||
data["mode_reglement_code"] = mode_regl
|
||
|
||
# Libellé du mode de règlement
|
||
if mode_regl:
|
||
try:
|
||
mode_obj = getattr(fourn_obj, "ModeReglement", None)
|
||
if mode_obj:
|
||
mode_obj.Read()
|
||
data["mode_reglement_libelle"] = getattr(
|
||
mode_obj, "M_Intitule", ""
|
||
).strip()
|
||
else:
|
||
data["mode_reglement_libelle"] = ""
|
||
except:
|
||
data["mode_reglement_libelle"] = ""
|
||
else:
|
||
data["mode_reglement_libelle"] = ""
|
||
except:
|
||
data["mode_reglement_code"] = ""
|
||
data["mode_reglement_libelle"] = ""
|
||
|
||
# === COORDONNÉES BANCAIRES (IBAN) ===
|
||
data["coordonnees_bancaires"] = []
|
||
|
||
try:
|
||
# Sage peut avoir plusieurs comptes bancaires
|
||
factory_banque = getattr(fourn_obj, "FactoryBanque", None)
|
||
|
||
if factory_banque:
|
||
index = 1
|
||
while index <= 5: # Max 5 comptes bancaires
|
||
try:
|
||
banque_persist = factory_banque.List(index)
|
||
if banque_persist is None:
|
||
break
|
||
|
||
banque = win32com.client.CastTo(
|
||
banque_persist, "IBOBanque3"
|
||
)
|
||
banque.Read()
|
||
|
||
compte_bancaire = {
|
||
"banque_nom": getattr(
|
||
banque, "BI_Intitule", ""
|
||
).strip(),
|
||
"iban": getattr(banque, "RIB_Iban", "").strip(),
|
||
"bic": getattr(banque, "RIB_Bic", "").strip(),
|
||
"code_banque": getattr(
|
||
banque, "RIB_Banque", ""
|
||
).strip(),
|
||
"code_guichet": getattr(
|
||
banque, "RIB_Guichet", ""
|
||
).strip(),
|
||
"numero_compte": getattr(
|
||
banque, "RIB_Compte", ""
|
||
).strip(),
|
||
"cle_rib": getattr(banque, "RIB_Cle", "").strip(),
|
||
}
|
||
|
||
# Ne garder que si IBAN ou RIB complet
|
||
if (
|
||
compte_bancaire["iban"]
|
||
or compte_bancaire["numero_compte"]
|
||
):
|
||
data["coordonnees_bancaires"].append(compte_bancaire)
|
||
|
||
index += 1
|
||
except:
|
||
break
|
||
except Exception as e:
|
||
logger.debug(
|
||
f"⚠️ Erreur coordonnées bancaires fournisseur {numero}: {e}"
|
||
)
|
||
|
||
# IBAN principal (premier de la liste)
|
||
if data["coordonnees_bancaires"]:
|
||
data["iban_principal"] = data["coordonnees_bancaires"][0].get(
|
||
"iban", ""
|
||
)
|
||
data["bic_principal"] = data["coordonnees_bancaires"][0].get("bic", "")
|
||
else:
|
||
data["iban_principal"] = ""
|
||
data["bic_principal"] = ""
|
||
|
||
# === CONTACTS ===
|
||
data["contacts"] = []
|
||
|
||
try:
|
||
factory_contact = getattr(fourn_obj, "FactoryContact", None)
|
||
|
||
if factory_contact:
|
||
index = 1
|
||
while index <= 20: # Max 20 contacts
|
||
try:
|
||
contact_persist = factory_contact.List(index)
|
||
if contact_persist is None:
|
||
break
|
||
|
||
contact = win32com.client.CastTo(
|
||
contact_persist, "IBOContact3"
|
||
)
|
||
contact.Read()
|
||
|
||
contact_data = {
|
||
"nom": getattr(contact, "CO_Nom", "").strip(),
|
||
"prenom": getattr(contact, "CO_Prenom", "").strip(),
|
||
"fonction": getattr(contact, "CO_Fonction", "").strip(),
|
||
"service": getattr(contact, "CO_Service", "").strip(),
|
||
"telephone": getattr(
|
||
contact, "CO_Telephone", ""
|
||
).strip(),
|
||
"portable": getattr(contact, "CO_Portable", "").strip(),
|
||
"email": getattr(contact, "CO_EMail", "").strip(),
|
||
}
|
||
|
||
# Nom complet formaté
|
||
nom_complet = f"{contact_data['prenom']} {contact_data['nom']}".strip()
|
||
if nom_complet:
|
||
contact_data["nom_complet"] = nom_complet
|
||
else:
|
||
contact_data["nom_complet"] = contact_data["nom"]
|
||
|
||
# Ne garder que si nom existe
|
||
if contact_data["nom"]:
|
||
data["contacts"].append(contact_data)
|
||
|
||
index += 1
|
||
except:
|
||
break
|
||
except Exception as e:
|
||
logger.debug(f"⚠️ Erreur contacts fournisseur {numero}: {e}")
|
||
|
||
# Nombre de contacts
|
||
data["nb_contacts"] = len(data["contacts"])
|
||
|
||
# Contact principal (premier de la liste)
|
||
if data["contacts"]:
|
||
data["contact_principal"] = data["contacts"][0]
|
||
else:
|
||
data["contact_principal"] = None
|
||
|
||
# === STATISTIQUES & INFORMATIONS COMMERCIALES ===
|
||
# Encours autorisé
|
||
try:
|
||
data["encours_autorise"] = float(getattr(fourn_obj, "CT_Encours", 0.0))
|
||
except:
|
||
data["encours_autorise"] = 0.0
|
||
|
||
# Chiffre d'affaires annuel
|
||
try:
|
||
data["ca_annuel"] = float(getattr(fourn_obj, "CT_ChiffreAffaire", 0.0))
|
||
except:
|
||
data["ca_annuel"] = 0.0
|
||
|
||
# Compte général
|
||
try:
|
||
data["compte_general"] = getattr(fourn_obj, "CG_Num", "").strip()
|
||
except:
|
||
data["compte_general"] = ""
|
||
|
||
# === DATES ===
|
||
try:
|
||
date_creation = getattr(fourn_obj, "CT_DateCreate", None)
|
||
data["date_creation"] = str(date_creation) if date_creation else ""
|
||
except:
|
||
data["date_creation"] = ""
|
||
|
||
try:
|
||
date_modif = getattr(fourn_obj, "CT_DateModif", None)
|
||
data["date_modification"] = str(date_modif) if date_modif else ""
|
||
except:
|
||
data["date_modification"] = ""
|
||
|
||
return data
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur extraction fournisseur: {e}", exc_info=True)
|
||
# Retourner structure minimale en cas d'erreur
|
||
return {
|
||
"numero": getattr(fourn_obj, "CT_Num", "").strip(),
|
||
"intitule": getattr(fourn_obj, "CT_Intitule", "").strip(),
|
||
"type": 1,
|
||
"est_fournisseur": True,
|
||
"est_actif": True,
|
||
"en_sommeil": False,
|
||
"adresse": "",
|
||
"complement": "",
|
||
"code_postal": "",
|
||
"ville": "",
|
||
"region": "",
|
||
"pays": "",
|
||
"adresse_complete": "",
|
||
"telephone": "",
|
||
"portable": "",
|
||
"telecopie": "",
|
||
"email": "",
|
||
"site_web": "",
|
||
"siret": "",
|
||
"siren": "",
|
||
"tva_intra": "",
|
||
"code_naf": "",
|
||
"forme_juridique": "",
|
||
"categorie_tarifaire": None,
|
||
"categorie_comptable": None,
|
||
"conditions_reglement_code": "",
|
||
"conditions_reglement_libelle": "",
|
||
"mode_reglement_code": "",
|
||
"mode_reglement_libelle": "",
|
||
"iban_principal": "",
|
||
"bic_principal": "",
|
||
"coordonnees_bancaires": [],
|
||
"contacts": [],
|
||
"nb_contacts": 0,
|
||
"contact_principal": None,
|
||
"encours_autorise": 0.0,
|
||
"ca_annuel": 0.0,
|
||
"compte_general": "",
|
||
"date_creation": "",
|
||
"date_modification": "",
|
||
}
|
||
|
||
def creer_devis_enrichi(self, devis_data: dict, forcer_brouillon: bool = False):
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
logger.info(
|
||
f"🚀 Début création devis pour client {devis_data['client']['code']} "
|
||
f"(brouillon={forcer_brouillon})"
|
||
)
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("✅ Transaction Sage démarrée")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ BeginTrans échoué: {e}")
|
||
|
||
try:
|
||
# ===== CRÉATION DOCUMENT =====
|
||
process = self.cial.CreateProcess_Document(0)
|
||
doc = process.Document
|
||
|
||
try:
|
||
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
|
||
except:
|
||
pass
|
||
|
||
logger.info("📄 Document devis créé")
|
||
|
||
# ===== DATE =====
|
||
import pywintypes
|
||
|
||
if isinstance(devis_data["date_devis"], str):
|
||
try:
|
||
date_obj = datetime.fromisoformat(devis_data["date_devis"])
|
||
except:
|
||
date_obj = datetime.now()
|
||
elif isinstance(devis_data["date_devis"], date):
|
||
date_obj = datetime.combine(
|
||
devis_data["date_devis"], datetime.min.time()
|
||
)
|
||
else:
|
||
date_obj = datetime.now()
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
logger.info(f"📅 Date définie: {date_obj.date()}")
|
||
|
||
# ===== CLIENT =====
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist_client = factory_client.ReadNumero(
|
||
devis_data["client"]["code"]
|
||
)
|
||
|
||
if not persist_client:
|
||
raise ValueError(
|
||
f"❌ Client {devis_data['client']['code']} introuvable"
|
||
)
|
||
|
||
client_obj = self._cast_client(persist_client)
|
||
if not client_obj:
|
||
raise ValueError(
|
||
f"❌ Impossible de charger le client {devis_data['client']['code']}"
|
||
)
|
||
|
||
doc.SetDefaultClient(client_obj)
|
||
|
||
# ✅ STATUT: Définir SEULEMENT si brouillon demandé
|
||
doc.DO_Statut = 2
|
||
logger.info("📊 Statut forcé: 0 (Brouillon)")
|
||
# Sinon, laisser Sage décider (généralement 2 = Accepté)
|
||
|
||
doc.Write()
|
||
logger.info(f"👤 Client {devis_data['client']['code']} associé")
|
||
|
||
# ===== LIGNES =====
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
logger.info(f"📦 Ajout de {len(devis_data['lignes'])} lignes...")
|
||
|
||
for idx, ligne_data in enumerate(devis_data["lignes"], 1):
|
||
logger.debug(
|
||
f"--- Ligne {idx}: {ligne_data['article_code']} ---"
|
||
)
|
||
|
||
# Charger l'article
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"❌ Article {ligne_data['article_code']} introuvable"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0))
|
||
designation_sage = getattr(article_obj, "AR_Design", "")
|
||
|
||
if prix_sage == 0:
|
||
logger.warning(
|
||
f"⚠️ Article {ligne_data['article_code']} a un prix = 0€"
|
||
)
|
||
|
||
# Créer la ligne
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
except:
|
||
ligne_obj.DL_Design = (
|
||
designation_sage
|
||
or ligne_data.get("designation", "")
|
||
)
|
||
ligne_obj.DL_Qte = quantite
|
||
|
||
# Prix
|
||
prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
prix_a_utiliser = ligne_data.get("prix_unitaire_ht")
|
||
|
||
if prix_a_utiliser is not None and prix_a_utiliser > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser)
|
||
elif prix_auto == 0:
|
||
if prix_sage == 0:
|
||
raise ValueError(
|
||
f"Prix nul pour article {ligne_data['article_code']}"
|
||
)
|
||
ligne_obj.DL_PrixUnitaire = float(prix_sage)
|
||
|
||
# Remise
|
||
remise = ligne_data.get("remise_pourcentage", 0)
|
||
if remise > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(remise)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Remise non appliquée: {e}")
|
||
|
||
ligne_obj.Write()
|
||
|
||
logger.info(f"✅ {len(devis_data['lignes'])} lignes écrites")
|
||
|
||
# ===== VALIDATION =====
|
||
doc.Write()
|
||
|
||
# ✅ PROCESS() uniquement si pas en brouillon
|
||
if not forcer_brouillon:
|
||
logger.info("🔄 Lancement Process()...")
|
||
process.Process()
|
||
else:
|
||
# En brouillon, Process() peut échouer, on essaie mais on ignore l'erreur
|
||
try:
|
||
process.Process()
|
||
logger.info("✅ Process() appelé (brouillon)")
|
||
except:
|
||
logger.debug("⚠️ Process() ignoré pour brouillon")
|
||
|
||
# ===== RÉCUPÉRATION NUMÉRO =====
|
||
numero_devis = None
|
||
|
||
# Méthode 1: DocumentResult
|
||
try:
|
||
doc_result = process.DocumentResult
|
||
if doc_result:
|
||
doc_result = win32com.client.CastTo(
|
||
doc_result, "IBODocumentVente3"
|
||
)
|
||
doc_result.Read()
|
||
numero_devis = getattr(doc_result, "DO_Piece", "")
|
||
except:
|
||
pass
|
||
|
||
# Méthode 2: Document direct
|
||
if not numero_devis:
|
||
numero_devis = getattr(doc, "DO_Piece", "")
|
||
|
||
# Méthode 3: SetDefaultNumPiece
|
||
if not numero_devis:
|
||
try:
|
||
doc.SetDefaultNumPiece()
|
||
doc.Write()
|
||
doc.Read()
|
||
numero_devis = getattr(doc, "DO_Piece", "")
|
||
except:
|
||
pass
|
||
|
||
if not numero_devis:
|
||
raise RuntimeError("❌ Numéro devis vide après création")
|
||
|
||
logger.info(f"📄 Numéro: {numero_devis}")
|
||
|
||
# ===== COMMIT =====
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
logger.info("✅ Transaction committée")
|
||
except:
|
||
pass
|
||
|
||
# ===== RELECTURE SIMPLIFIÉE (pas d'attente excessive) =====
|
||
# Attendre juste 500ms pour l'indexation
|
||
time.sleep(0.5)
|
||
|
||
factory_doc = self.cial.FactoryDocumentVente
|
||
persist_reread = factory_doc.ReadPiece(0, numero_devis)
|
||
|
||
if not persist_reread:
|
||
# Si ReadPiece échoue, chercher dans les 100 premiers
|
||
logger.debug("ReadPiece échoué, recherche dans List()...")
|
||
index = 1
|
||
while index < 100:
|
||
try:
|
||
persist_test = factory_doc.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
doc_test = win32com.client.CastTo(
|
||
persist_test, "IBODocumentVente3"
|
||
)
|
||
doc_test.Read()
|
||
|
||
if (
|
||
getattr(doc_test, "DO_Type", -1) == 0
|
||
and getattr(doc_test, "DO_Piece", "")
|
||
== numero_devis
|
||
):
|
||
persist_reread = persist_test
|
||
logger.info(f"✅ Document trouvé à l'index {index}")
|
||
break
|
||
|
||
index += 1
|
||
except:
|
||
index += 1
|
||
|
||
# Extraction des totaux
|
||
if persist_reread:
|
||
doc_final = win32com.client.CastTo(
|
||
persist_reread, "IBODocumentVente3"
|
||
)
|
||
doc_final.Read()
|
||
|
||
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
|
||
statut_final = getattr(doc_final, "DO_Statut", 0)
|
||
else:
|
||
# Fallback: calculer manuellement
|
||
total_calcule = sum(
|
||
l.get("montant_ligne_ht", 0) for l in devis_data["lignes"]
|
||
)
|
||
total_ht = total_calcule
|
||
total_ttc = round(total_calcule * 1.20, 2)
|
||
statut_final = 0 if forcer_brouillon else 2
|
||
|
||
logger.info(f"💰 Total HT: {total_ht}€")
|
||
logger.info(f"💰 Total TTC: {total_ttc}€")
|
||
logger.info(f"📊 Statut final: {statut_final}")
|
||
|
||
logger.info(
|
||
f"✅ ✅ ✅ DEVIS CRÉÉ: {numero_devis} - {total_ttc}€ TTC ✅ ✅ ✅"
|
||
)
|
||
|
||
return {
|
||
"numero_devis": numero_devis,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"nb_lignes": len(devis_data["lignes"]),
|
||
"client_code": devis_data["client"]["code"],
|
||
"date_devis": str(date_obj.date()),
|
||
"statut": statut_final,
|
||
}
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
logger.error("❌ Transaction annulée (rollback)")
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ ERREUR CRÉATION DEVIS: {e}", exc_info=True)
|
||
raise RuntimeError(f"Échec création devis: {str(e)}")
|
||
|
||
def lire_devis(self, numero_devis):
|
||
try:
|
||
# Lire le devis via SQL
|
||
devis = self._lire_document_sql(numero_devis, type_doc=0)
|
||
|
||
if not devis:
|
||
return None
|
||
|
||
# ✅ Vérifier si transformé (via SQL pour DO_Ref)
|
||
try:
|
||
verification = self.verifier_si_deja_transforme_sql(numero_devis, 0)
|
||
devis["a_deja_ete_transforme"] = verification.get(
|
||
"deja_transforme", False
|
||
)
|
||
devis["documents_cibles"] = verification.get("documents_cibles", [])
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur vérification transformation: {e}")
|
||
devis["a_deja_ete_transforme"] = False
|
||
devis["documents_cibles"] = []
|
||
|
||
logger.info(
|
||
f"✅ SQL: Devis {numero_devis} lu ({len(devis['lignes'])} lignes)"
|
||
)
|
||
return devis
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL lecture devis {numero_devis}: {e}")
|
||
return None
|
||
|
||
def lire_document(self, numero, type_doc):
|
||
return self._lire_document_sql(numero, type_doc)
|
||
|
||
def verifier_si_deja_transforme_sql(
|
||
self, numero_source: str, type_source: int
|
||
) -> Dict:
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# Types cibles selon le type source
|
||
types_cibles_map = {
|
||
0: [10, 60], # Devis → Commande ou Facture
|
||
10: [30, 60], # Commande → BL ou Facture
|
||
30: [60], # BL → Facture
|
||
}
|
||
|
||
types_cibles = types_cibles_map.get(type_source, [])
|
||
|
||
if not types_cibles:
|
||
return {"deja_transforme": False, "documents_cibles": []}
|
||
|
||
# Construire la clause WHERE avec OR pour chaque type
|
||
placeholders = ",".join(["?"] * len(types_cibles))
|
||
|
||
query = f"""
|
||
SELECT DO_Piece, DO_Type, DO_Date, DO_Ref, DO_TotalTTC, DO_Statut
|
||
FROM F_DOCENTETE
|
||
WHERE DO_Ref LIKE ?
|
||
AND DO_Type IN ({placeholders})
|
||
ORDER BY DO_Date DESC
|
||
"""
|
||
|
||
params = [f"%{numero_source}%"] + types_cibles
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
documents_cibles = []
|
||
|
||
for row in rows:
|
||
# Vérifier que DO_Ref correspond exactement (éviter les faux positifs)
|
||
ref_origine = self._safe_strip(row.DO_Ref)
|
||
|
||
if numero_source in ref_origine or ref_origine == numero_source:
|
||
type_libelle = {
|
||
10: "Bon de commande",
|
||
30: "Bon de livraison",
|
||
60: "Facture",
|
||
}.get(row.DO_Type, f"Type {row.DO_Type}")
|
||
|
||
documents_cibles.append(
|
||
{
|
||
"numero": self._safe_strip(row.DO_Piece),
|
||
"type": row.DO_Type,
|
||
"type_libelle": type_libelle,
|
||
"date": str(row.DO_Date) if row.DO_Date else "",
|
||
"reference": ref_origine,
|
||
"total_ttc": (
|
||
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
|
||
),
|
||
"statut": (
|
||
row.DO_Statut if row.DO_Statut is not None else 0
|
||
),
|
||
"methode_detection": "sql_do_ref",
|
||
}
|
||
)
|
||
|
||
logger.info(
|
||
f"✅ SQL: Vérification {numero_source} → {len(documents_cibles)} transformation(s)"
|
||
)
|
||
|
||
return {
|
||
"deja_transforme": len(documents_cibles) > 0,
|
||
"nb_transformations": len(documents_cibles),
|
||
"documents_cibles": documents_cibles,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL vérification transformation: {e}")
|
||
return {"deja_transforme": False, "documents_cibles": []}
|
||
|
||
def _get_type_libelle(self, type_doc: int) -> str:
|
||
"""Retourne le libellé d'un type de document"""
|
||
types = {
|
||
0: "Devis",
|
||
10: "Bon de commande",
|
||
20: "Préparation",
|
||
30: "Bon de livraison",
|
||
40: "Bon de retour",
|
||
50: "Bon d'avoir",
|
||
60: "Facture",
|
||
}
|
||
return types.get(type_doc, f"Type {type_doc}")
|
||
|
||
def transformer_document(
|
||
self, numero_source, type_source, type_cible, ignorer_controle_stock=True
|
||
):
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
type_source = int(type_source)
|
||
type_cible = int(type_cible)
|
||
|
||
logger.info(
|
||
f"[TRANSFORM] {numero_source} (type {type_source}) → type {type_cible}"
|
||
)
|
||
|
||
transformations_valides = {
|
||
(0, 10), # Devis → Commande
|
||
(0, 60), # Devis → Facture
|
||
(10, 30), # Commande → Bon de livraison
|
||
(10, 60), # Commande → Facture
|
||
(30, 60), # Bon de livraison → Facture
|
||
}
|
||
|
||
if (type_source, type_cible) not in transformations_valides:
|
||
raise ValueError(
|
||
f"Transformation non autorisée: {type_source} -> {type_cible}"
|
||
)
|
||
|
||
# ========================================
|
||
# FONCTION UTILITAIRE
|
||
# ========================================
|
||
def lire_erreurs_sage(obj, nom_obj=""):
|
||
"""Lit toutes les erreurs d'un objet Sage COM"""
|
||
erreurs = []
|
||
try:
|
||
if not hasattr(obj, "Errors") or obj.Errors is None:
|
||
return erreurs
|
||
|
||
nb_erreurs = 0
|
||
try:
|
||
nb_erreurs = obj.Errors.Count
|
||
except:
|
||
return erreurs
|
||
|
||
if nb_erreurs == 0:
|
||
return erreurs
|
||
|
||
for i in range(1, nb_erreurs + 1):
|
||
try:
|
||
err = None
|
||
try:
|
||
err = obj.Errors.Item(i)
|
||
except:
|
||
try:
|
||
err = obj.Errors(i)
|
||
except:
|
||
try:
|
||
err = obj.Errors.Item(i - 1)
|
||
except:
|
||
pass
|
||
|
||
if err is not None:
|
||
description = ""
|
||
field = ""
|
||
number = ""
|
||
|
||
for attr in ["Description", "Descr", "Message", "Text"]:
|
||
try:
|
||
val = getattr(err, attr, None)
|
||
if val:
|
||
description = str(val)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
for attr in ["Field", "FieldName", "Champ", "Property"]:
|
||
try:
|
||
val = getattr(err, attr, None)
|
||
if val:
|
||
field = str(val)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
for attr in ["Number", "Code", "ErrorCode", "Numero"]:
|
||
try:
|
||
val = getattr(err, attr, None)
|
||
if val is not None:
|
||
number = str(val)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
if description or field or number:
|
||
erreurs.append(
|
||
{
|
||
"source": nom_obj,
|
||
"index": i,
|
||
"description": description or "Erreur inconnue",
|
||
"field": field or "?",
|
||
"number": number or "?",
|
||
}
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"Erreur lecture erreur {i}: {e}")
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"Erreur globale lecture erreurs {nom_obj}: {e}")
|
||
|
||
return erreurs
|
||
|
||
# Vérification doublons
|
||
logger.info("[TRANSFORM] Vérification des doublons via DO_Ref...")
|
||
verification = self.verifier_si_deja_transforme(numero_source, type_source)
|
||
|
||
if verification["deja_transforme"]:
|
||
docs_existants = verification["documents_cibles"]
|
||
docs_meme_type = [d for d in docs_existants if d["type"] == type_cible]
|
||
|
||
if docs_meme_type:
|
||
nums = [d["numero"] for d in docs_meme_type]
|
||
error_msg = (
|
||
f"❌ Le document {numero_source} a déjà été transformé "
|
||
f"en {self._get_type_libelle(type_cible)}. "
|
||
f"Document(s) existant(s) : {', '.join(nums)}"
|
||
)
|
||
logger.error(f"[TRANSFORM] {error_msg}")
|
||
raise ValueError(error_msg)
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
# ========================================
|
||
# ÉTAPE 1 : LIRE LE DOCUMENT SOURCE
|
||
# ========================================
|
||
factory = self.cial.FactoryDocumentVente
|
||
persist_source = factory.ReadPiece(type_source, numero_source)
|
||
|
||
if not persist_source:
|
||
persist_source = self._find_document_in_list(
|
||
numero_source, type_source
|
||
)
|
||
if not persist_source:
|
||
raise ValueError(
|
||
f"Document {numero_source} (type {type_source}) introuvable"
|
||
)
|
||
|
||
doc_source = win32com.client.CastTo(persist_source, "IBODocumentVente3")
|
||
doc_source.Read()
|
||
|
||
statut_actuel = getattr(doc_source, "DO_Statut", 0)
|
||
logger.info(
|
||
f"[TRANSFORM] Source: type={type_source}, statut={statut_actuel}"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : EXTRAIRE DONNÉES SOURCE
|
||
# ========================================
|
||
logger.info("[TRANSFORM] Extraction données source...")
|
||
|
||
# Client
|
||
client_code = ""
|
||
client_obj_source = None
|
||
try:
|
||
client_obj_source = getattr(doc_source, "Client", None)
|
||
if client_obj_source:
|
||
client_obj_source.Read()
|
||
client_code = getattr(client_obj_source, "CT_Num", "").strip()
|
||
except Exception as e:
|
||
logger.error(f"Erreur lecture client source: {e}")
|
||
raise ValueError("Impossible de lire le client du document source")
|
||
|
||
if not client_code:
|
||
raise ValueError("Client introuvable dans document source")
|
||
|
||
logger.info(f"[TRANSFORM] Client: {client_code}")
|
||
|
||
# Date et référence
|
||
date_source = getattr(doc_source, "DO_Date", None)
|
||
reference_pour_cible = numero_source
|
||
logger.info(f"[TRANSFORM] Référence à copier: {reference_pour_cible}")
|
||
|
||
# Champs à copier
|
||
champs_source = {}
|
||
champs_a_copier = [
|
||
"DO_Souche",
|
||
"DO_Regime",
|
||
"DO_CodeJournal",
|
||
"DO_Coord01",
|
||
"DO_TypeCalcul",
|
||
"DO_Devise",
|
||
"DO_Cours",
|
||
"DO_Period",
|
||
"DO_Expedit",
|
||
"DO_NbFacture",
|
||
"DO_BLFact",
|
||
"DO_TxEsworte",
|
||
"DO_Reliquat",
|
||
"DO_Imprim",
|
||
"DO_Ventile",
|
||
"DO_Motif",
|
||
]
|
||
|
||
for champ in champs_a_copier:
|
||
try:
|
||
val = getattr(doc_source, champ, None)
|
||
if val is not None:
|
||
champs_source[champ] = val
|
||
except:
|
||
pass
|
||
|
||
# Infos règlement client
|
||
client_mode_regl = None
|
||
client_cond_regl = None
|
||
|
||
if client_obj_source:
|
||
try:
|
||
client_mode_regl = getattr(
|
||
client_obj_source, "CT_ModeRegl", None
|
||
)
|
||
if client_mode_regl:
|
||
logger.info(
|
||
f"[TRANSFORM] Mode règlement client: {client_mode_regl}"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
client_cond_regl = getattr(
|
||
client_obj_source, "CT_CondRegl", None
|
||
)
|
||
if client_cond_regl:
|
||
logger.info(
|
||
f"[TRANSFORM] Conditions règlement client: {client_cond_regl}"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : EXTRACTION LIGNES
|
||
# ========================================
|
||
lignes_source = []
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
try:
|
||
factory_lignes_source = getattr(
|
||
doc_source, "FactoryDocumentLigne", None
|
||
)
|
||
if not factory_lignes_source:
|
||
factory_lignes_source = getattr(
|
||
doc_source, "FactoryDocumentVenteLigne", None
|
||
)
|
||
|
||
if factory_lignes_source:
|
||
index = 1
|
||
while index <= 1000:
|
||
try:
|
||
ligne_p = factory_lignes_source.List(index)
|
||
if ligne_p is None:
|
||
break
|
||
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
ligne.Read()
|
||
|
||
# Référence article
|
||
article_ref = ""
|
||
try:
|
||
article_ref = getattr(ligne, "AR_Ref", "").strip()
|
||
if not article_ref:
|
||
article_obj = getattr(ligne, "Article", None)
|
||
if article_obj:
|
||
article_obj.Read()
|
||
article_ref = getattr(
|
||
article_obj, "AR_Ref", ""
|
||
).strip()
|
||
except:
|
||
pass
|
||
|
||
# Prix unitaire
|
||
prix_unitaire = float(
|
||
getattr(ligne, "DL_PrixUnitaire", 0.0)
|
||
)
|
||
|
||
# Si prix = 0, récupérer depuis l'article
|
||
if prix_unitaire == 0 and article_ref:
|
||
try:
|
||
persist_article = factory_article.ReadReference(
|
||
article_ref
|
||
)
|
||
if persist_article:
|
||
article_obj_price = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj_price.Read()
|
||
prix_unitaire = float(
|
||
getattr(
|
||
article_obj_price, "AR_PrixVen", 0.0
|
||
)
|
||
)
|
||
logger.info(
|
||
f" Prix récupéré depuis article {article_ref}: {prix_unitaire}€"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
# ✅ NOUVEAU : Récupérer le DL_MvtStock de la ligne source
|
||
mvt_stock_source = 0
|
||
try:
|
||
mvt_stock_source = int(
|
||
getattr(ligne, "DL_MvtStock", 0)
|
||
)
|
||
except:
|
||
pass
|
||
|
||
lignes_source.append(
|
||
{
|
||
"article_ref": article_ref,
|
||
"designation": getattr(ligne, "DL_Design", ""),
|
||
"quantite": float(
|
||
getattr(ligne, "DL_Qte", 0.0)
|
||
),
|
||
"prix_unitaire": prix_unitaire,
|
||
"remise": float(
|
||
getattr(ligne, "DL_Remise01REM_Valeur", 0.0)
|
||
),
|
||
"type_remise": int(
|
||
getattr(ligne, "DL_Remise01REM_Type", 0)
|
||
),
|
||
"montant_ht": float(
|
||
getattr(ligne, "DL_MontantHT", 0.0)
|
||
),
|
||
"mvt_stock": mvt_stock_source, # ✅ Conservé !
|
||
}
|
||
)
|
||
|
||
index += 1
|
||
except Exception as e:
|
||
logger.debug(f"Erreur ligne {index}: {e}")
|
||
break
|
||
|
||
except Exception as e:
|
||
logger.error(f"Erreur extraction lignes: {e}")
|
||
raise ValueError(
|
||
"Impossible d'extraire les lignes du document source"
|
||
)
|
||
|
||
nb_lignes = len(lignes_source)
|
||
logger.info(f"[TRANSFORM] {nb_lignes} lignes extraites")
|
||
|
||
if nb_lignes == 0:
|
||
raise ValueError("Document source vide (aucune ligne)")
|
||
|
||
total_attendu_ht = sum(l["montant_ht"] for l in lignes_source)
|
||
logger.info(
|
||
f"[TRANSFORM] Total HT attendu (calculé): {total_attendu_ht}€"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : TRANSACTION
|
||
# ========================================
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("[TRANSFORM] Transaction démarrée")
|
||
except:
|
||
logger.debug("[TRANSFORM] BeginTrans non disponible")
|
||
|
||
try:
|
||
# ========================================
|
||
# ÉTAPE 5 : CRÉER DOCUMENT CIBLE
|
||
# ========================================
|
||
logger.info(f"[TRANSFORM] Création document type {type_cible}...")
|
||
|
||
process = self.cial.CreateProcess_Document(type_cible)
|
||
if not process:
|
||
raise RuntimeError(
|
||
f"CreateProcess_Document({type_cible}) a retourné None"
|
||
)
|
||
|
||
doc_cible = process.Document
|
||
try:
|
||
doc_cible = win32com.client.CastTo(
|
||
doc_cible, "IBODocumentVente3"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
logger.info("[TRANSFORM] Document cible créé")
|
||
|
||
# ========================================
|
||
# ÉTAPE 6 : DÉFINIR LA DATE
|
||
# ========================================
|
||
import pywintypes
|
||
|
||
if date_source:
|
||
try:
|
||
doc_cible.DO_Date = date_source
|
||
logger.info(f"[TRANSFORM] Date copiée: {date_source}")
|
||
except Exception as e:
|
||
logger.warning(f"Impossible de copier date: {e}")
|
||
doc_cible.DO_Date = pywintypes.Time(datetime.now())
|
||
else:
|
||
doc_cible.DO_Date = pywintypes.Time(datetime.now())
|
||
|
||
# ========================================
|
||
# ÉTAPE 7 : ASSOCIER LE CLIENT
|
||
# ========================================
|
||
logger.info(f"[TRANSFORM] Association client {client_code}...")
|
||
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist_client = factory_client.ReadNumero(client_code)
|
||
|
||
if not persist_client:
|
||
raise ValueError(f"Client {client_code} introuvable")
|
||
|
||
client_obj_cible = self._cast_client(persist_client)
|
||
if not client_obj_cible:
|
||
raise ValueError(f"Impossible de charger client {client_code}")
|
||
|
||
try:
|
||
doc_cible.SetDefaultClient(client_obj_cible)
|
||
logger.info("[TRANSFORM] SetDefaultClient() appelé")
|
||
except Exception as e:
|
||
try:
|
||
doc_cible.Client = client_obj_cible
|
||
logger.info(
|
||
"[TRANSFORM] Client assigné via propriété .Client"
|
||
)
|
||
except Exception as e2:
|
||
raise ValueError(f"Impossible d'associer le client: {e2}")
|
||
|
||
# DO_Ref AVANT 1er Write
|
||
try:
|
||
doc_cible.DO_Ref = reference_pour_cible
|
||
logger.info(
|
||
f"[TRANSFORM] DO_Ref défini: {reference_pour_cible}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Impossible de définir DO_Ref: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 7.5 : COPIER CHAMPS
|
||
# ========================================
|
||
for champ, valeur in champs_source.items():
|
||
try:
|
||
setattr(doc_cible, champ, valeur)
|
||
logger.debug(f"[TRANSFORM] {champ} copié: {valeur}")
|
||
except Exception as e:
|
||
logger.debug(f"[TRANSFORM] {champ} non copié: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 7.6 : CHAMPS SPÉCIFIQUES FACTURES
|
||
# ========================================
|
||
if type_cible == 60:
|
||
logger.info("[TRANSFORM] Configuration champs factures...")
|
||
|
||
# DO_Souche
|
||
try:
|
||
souche = champs_source.get("DO_Souche", 0)
|
||
doc_cible.DO_Souche = souche
|
||
logger.debug(f" ✅ DO_Souche: {souche}")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DO_Souche: {e}")
|
||
|
||
# DO_Regime
|
||
try:
|
||
regime = champs_source.get("DO_Regime", 0)
|
||
doc_cible.DO_Regime = regime
|
||
logger.debug(f" ✅ DO_Regime: {regime}")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DO_Regime: {e}")
|
||
|
||
# DO_Transaction
|
||
try:
|
||
doc_cible.DO_Transaction = 11
|
||
logger.debug(f" ✅ DO_Transaction: 11")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DO_Transaction: {e}")
|
||
|
||
# Mode règlement
|
||
if client_mode_regl:
|
||
try:
|
||
doc_cible.DO_ModeRegl = client_mode_regl
|
||
logger.info(f" ✅ DO_ModeRegl: {client_mode_regl}")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DO_ModeRegl: {e}")
|
||
|
||
# Conditions règlement
|
||
if client_cond_regl:
|
||
try:
|
||
doc_cible.DO_CondRegl = client_cond_regl
|
||
logger.info(f" ✅ DO_CondRegl: {client_cond_regl}")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DO_CondRegl: {e}")
|
||
|
||
# 1er Write
|
||
doc_cible.Write()
|
||
logger.info("[TRANSFORM] Document initialisé (1er Write)")
|
||
|
||
# ========================================
|
||
# ÉTAPE 8 : COPIER LIGNES (AVEC GESTION STOCK)
|
||
# ========================================
|
||
logger.info(f"[TRANSFORM] Copie de {nb_lignes} lignes...")
|
||
|
||
if ignorer_controle_stock:
|
||
logger.info("[TRANSFORM] ⚠️ Contrôle de stock DÉSACTIVÉ")
|
||
|
||
try:
|
||
factory_lignes_cible = doc_cible.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes_cible = doc_cible.FactoryDocumentVenteLigne
|
||
|
||
lignes_creees = 0
|
||
|
||
for idx, ligne_data in enumerate(lignes_source, 1):
|
||
logger.debug(f"[TRANSFORM] Ligne {idx}/{nb_lignes}")
|
||
|
||
article_ref = ligne_data["article_ref"]
|
||
if not article_ref:
|
||
logger.warning(
|
||
f"Ligne {idx}: pas de référence article, skip"
|
||
)
|
||
continue
|
||
|
||
persist_article = factory_article.ReadReference(article_ref)
|
||
if not persist_article:
|
||
logger.warning(
|
||
f"Ligne {idx}: article {article_ref} introuvable, skip"
|
||
)
|
||
continue
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
ligne_persist = factory_lignes_cible.Create()
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = ligne_data["quantite"]
|
||
prix = ligne_data["prix_unitaire"]
|
||
|
||
# ✅ CRITIQUE : Associer l'article AVANT de définir les flags stock
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(article_ref, quantite)
|
||
logger.debug(f" SetDefaultArticleReference OK")
|
||
except Exception as e1:
|
||
logger.debug(f" SetDefaultArticleReference échoué: {e1}")
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
logger.debug(f" SetDefaultArticle OK")
|
||
except Exception as e2:
|
||
logger.debug(f" SetDefaultArticle échoué: {e2}")
|
||
ligne_obj.DL_Design = ligne_data["designation"]
|
||
ligne_obj.DL_Qte = quantite
|
||
logger.debug(f" Configuration manuelle")
|
||
|
||
# ✅ APRÈS association : Désactiver le contrôle de stock
|
||
if ignorer_controle_stock:
|
||
# Méthode 1 : Copier DL_MvtStock depuis la source
|
||
try:
|
||
mvt_stock_source = ligne_data.get("mvt_stock", 0)
|
||
ligne_obj.DL_MvtStock = mvt_stock_source
|
||
logger.debug(
|
||
f" ✅ DL_MvtStock = {mvt_stock_source} (copié depuis source)"
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DL_MvtStock: {e}")
|
||
|
||
# Méthode 2 : DL_NoStock = 1
|
||
try:
|
||
ligne_obj.DL_NoStock = 1
|
||
logger.debug(f" ✅ DL_NoStock = 1")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DL_NoStock: {e}")
|
||
|
||
# ✅ MÉTHODE 3 CRITIQUE : DL_NonLivre = quantité
|
||
# Indique que la quantité n'est PAS livrée, donc pas de sortie de stock
|
||
try:
|
||
ligne_obj.DL_NonLivre = quantite
|
||
logger.debug(
|
||
f" ✅ DL_NonLivre = {quantite} (évite sortie stock)"
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ DL_NonLivre: {e}")
|
||
|
||
# Prix unitaire
|
||
if prix > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix)
|
||
logger.debug(f" Prix forcé: {prix}€")
|
||
|
||
# Remise
|
||
remise = ligne_data["remise"]
|
||
if remise > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(remise)
|
||
ligne_obj.DL_Remise01REM_Type = ligne_data[
|
||
"type_remise"
|
||
]
|
||
logger.debug(f" Remise: {remise}%")
|
||
except:
|
||
pass
|
||
|
||
# Écrire la ligne
|
||
try:
|
||
ligne_obj.Write()
|
||
lignes_creees += 1
|
||
logger.debug(f" ✅ Ligne {idx} écrite")
|
||
except Exception as e:
|
||
logger.error(f" ❌ Erreur écriture ligne {idx}: {e}")
|
||
|
||
# Lire erreurs
|
||
erreurs_ligne = lire_erreurs_sage(ligne_obj, f"Ligne_{idx}")
|
||
for err in erreurs_ligne:
|
||
logger.error(
|
||
f" {err['field']}: {err['description']}"
|
||
)
|
||
|
||
continue
|
||
|
||
logger.info(f"[TRANSFORM] {lignes_creees} lignes créées")
|
||
|
||
if lignes_creees == 0:
|
||
raise ValueError("Aucune ligne n'a pu être créée")
|
||
|
||
# ========================================
|
||
# ÉTAPE 9 : WRITE FINAL
|
||
# ========================================
|
||
logger.info("[TRANSFORM] Write() final avant Process()...")
|
||
doc_cible.Write()
|
||
|
||
# ========================================
|
||
# ÉTAPE 10 : PROCESS()
|
||
# ========================================
|
||
logger.info("[TRANSFORM] Appel Process()...")
|
||
|
||
try:
|
||
process.Process()
|
||
logger.info("[TRANSFORM] Process() réussi !")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[TRANSFORM] ERREUR Process(): {e}")
|
||
|
||
toutes_erreurs = []
|
||
|
||
# Erreurs du process
|
||
erreurs_process = lire_erreurs_sage(process, "Process")
|
||
if erreurs_process:
|
||
logger.error(
|
||
f"[TRANSFORM] {len(erreurs_process)} erreur(s) process:"
|
||
)
|
||
for err in erreurs_process:
|
||
logger.error(
|
||
f" {err['field']}: {err['description']} (code: {err['number']})"
|
||
)
|
||
toutes_erreurs.append(
|
||
f"{err['field']}: {err['description']}"
|
||
)
|
||
|
||
# Erreurs du document
|
||
erreurs_doc = lire_erreurs_sage(doc_cible, "Document")
|
||
if erreurs_doc:
|
||
logger.error(
|
||
f"[TRANSFORM] {len(erreurs_doc)} erreur(s) document:"
|
||
)
|
||
for err in erreurs_doc:
|
||
logger.error(
|
||
f" {err['field']}: {err['description']} (code: {err['number']})"
|
||
)
|
||
toutes_erreurs.append(
|
||
f"{err['field']}: {err['description']}"
|
||
)
|
||
|
||
# Construire message
|
||
if toutes_erreurs:
|
||
error_msg = (
|
||
f"Process() échoué: {' | '.join(toutes_erreurs)}"
|
||
)
|
||
else:
|
||
error_msg = f"Process() échoué: {str(e)}"
|
||
|
||
# Conseil stock
|
||
if "stock" in error_msg.lower() or "2881" in error_msg:
|
||
error_msg += " | CONSEIL: Vérifiez le stock ou créez le document manuellement dans Sage."
|
||
|
||
logger.error(f"[TRANSFORM] {error_msg}")
|
||
raise RuntimeError(error_msg)
|
||
|
||
# ========================================
|
||
# ÉTAPE 11 : RÉCUPÉRER NUMÉRO
|
||
# ========================================
|
||
numero_cible = None
|
||
total_ht_final = 0.0
|
||
total_ttc_final = 0.0
|
||
|
||
# DocumentResult
|
||
try:
|
||
doc_result = process.DocumentResult
|
||
if doc_result:
|
||
doc_result = win32com.client.CastTo(
|
||
doc_result, "IBODocumentVente3"
|
||
)
|
||
doc_result.Read()
|
||
numero_cible = getattr(doc_result, "DO_Piece", "")
|
||
total_ht_final = float(
|
||
getattr(doc_result, "DO_TotalHT", 0.0)
|
||
)
|
||
total_ttc_final = float(
|
||
getattr(doc_result, "DO_TotalTTC", 0.0)
|
||
)
|
||
logger.info(
|
||
f"[TRANSFORM] DocumentResult: {numero_cible}, {total_ht_final}€ HT"
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"[TRANSFORM] DocumentResult non disponible: {e}")
|
||
|
||
# Relire doc_cible
|
||
if not numero_cible:
|
||
try:
|
||
doc_cible.Read()
|
||
numero_cible = getattr(doc_cible, "DO_Piece", "")
|
||
total_ht_final = float(
|
||
getattr(doc_cible, "DO_TotalHT", 0.0)
|
||
)
|
||
total_ttc_final = float(
|
||
getattr(doc_cible, "DO_TotalTTC", 0.0)
|
||
)
|
||
logger.info(
|
||
f"[TRANSFORM] doc_cible relu: {numero_cible}, {total_ht_final}€ HT"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
if not numero_cible:
|
||
raise RuntimeError("Numéro document cible vide après Process()")
|
||
|
||
logger.info(f"[TRANSFORM] Document cible créé: {numero_cible}")
|
||
logger.info(
|
||
f"[TRANSFORM] Totaux: {total_ht_final}€ HT / {total_ttc_final}€ TTC"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 12 : COMMIT
|
||
# ========================================
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
logger.info("[TRANSFORM] Transaction committée")
|
||
except:
|
||
pass
|
||
|
||
time.sleep(1)
|
||
|
||
logger.info(
|
||
f"[TRANSFORM] ✅ SUCCÈS: {numero_source} ({type_source}) -> "
|
||
f"{numero_cible} ({type_cible}) - {lignes_creees} lignes"
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"document_source": numero_source,
|
||
"document_cible": numero_cible,
|
||
"nb_lignes": lignes_creees,
|
||
"total_ht": total_ht_final,
|
||
"total_ttc": total_ttc_final,
|
||
}
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
logger.error("[TRANSFORM] Transaction annulée (rollback)")
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"[TRANSFORM] ❌ ERREUR: {e}", exc_info=True)
|
||
raise RuntimeError(f"Echec transformation: {str(e)}")
|
||
|
||
def _find_document_in_list(self, numero, type_doc):
|
||
"""Cherche un document dans List() si ReadPiece échoue"""
|
||
try:
|
||
factory = self.cial.FactoryDocumentVente
|
||
index = 1
|
||
|
||
while index < 10000:
|
||
try:
|
||
persist = factory.List(index)
|
||
if persist is None:
|
||
break
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
if (
|
||
getattr(doc, "DO_Type", -1) == type_doc
|
||
and getattr(doc, "DO_Piece", "") == numero
|
||
):
|
||
logger.info(f"[TRANSFORM] Document trouve a l'index {index}")
|
||
return persist
|
||
|
||
index += 1
|
||
except:
|
||
index += 1
|
||
continue
|
||
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"[TRANSFORM] Erreur recherche document: {e}")
|
||
return None
|
||
|
||
def mettre_a_jour_champ_libre(self, doc_id, type_doc, nom_champ, valeur):
|
||
"""Mise à jour champ libre pour Universign ID"""
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
factory = self.cial.FactoryDocumentVente
|
||
persist = factory.ReadPiece(type_doc, doc_id)
|
||
|
||
if persist:
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
try:
|
||
setattr(doc, f"DO_{nom_champ}", valeur)
|
||
doc.Write()
|
||
logger.debug(f"Champ libre {nom_champ} = {valeur} sur {doc_id}")
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"Impossible de mettre à jour {nom_champ}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Erreur MAJ champ libre: {e}")
|
||
|
||
return False
|
||
|
||
def _lire_client_obj(self, code_client):
|
||
"""Retourne l'objet client Sage brut (pour remises)"""
|
||
if not self.cial:
|
||
return None
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
factory = self.cial.CptaApplication.FactoryClient
|
||
persist = factory.ReadNumero(code_client)
|
||
|
||
if persist:
|
||
return self._cast_client(persist)
|
||
except:
|
||
pass
|
||
|
||
return None
|
||
|
||
def lire_contact_principal_client(self, code_client):
|
||
if not self.cial:
|
||
return None
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist_client = factory_client.ReadNumero(code_client)
|
||
|
||
if not persist_client:
|
||
return None
|
||
|
||
client = self._cast_client(persist_client)
|
||
if not client:
|
||
return None
|
||
|
||
# Récupérer infos contact principal
|
||
contact_info = {
|
||
"client_code": code_client,
|
||
"client_intitule": getattr(client, "CT_Intitule", ""),
|
||
"email": None,
|
||
"nom": None,
|
||
"telephone": None,
|
||
}
|
||
|
||
# Email principal depuis Telecom
|
||
try:
|
||
telecom = getattr(client, "Telecom", None)
|
||
if telecom:
|
||
contact_info["email"] = getattr(telecom, "EMail", "")
|
||
contact_info["telephone"] = getattr(telecom, "Telephone", "")
|
||
except:
|
||
pass
|
||
|
||
# Nom du contact
|
||
try:
|
||
contact_info["nom"] = (
|
||
getattr(client, "CT_Contact", "")
|
||
or contact_info["client_intitule"]
|
||
)
|
||
except:
|
||
contact_info["nom"] = contact_info["client_intitule"]
|
||
|
||
return contact_info
|
||
|
||
except Exception as e:
|
||
logger.error(f"Erreur lecture contact client {code_client}: {e}")
|
||
return None
|
||
|
||
def mettre_a_jour_derniere_relance(self, doc_id, type_doc):
|
||
date_relance = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
return self.mettre_a_jour_champ_libre(
|
||
doc_id, type_doc, "DerniereRelance", date_relance
|
||
)
|
||
|
||
def lister_tous_prospects(self, filtre=""):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
query = """
|
||
SELECT
|
||
CT_Num, CT_Intitule, CT_Adresse, CT_Ville,
|
||
CT_CodePostal, CT_Telephone, CT_EMail
|
||
FROM F_COMPTET
|
||
WHERE CT_Type = 0 AND CT_Prospect = 1
|
||
"""
|
||
|
||
params = []
|
||
|
||
if filtre:
|
||
query += " AND (CT_Num LIKE ? OR CT_Intitule LIKE ?)"
|
||
params.extend([f"%{filtre}%", f"%{filtre}%"])
|
||
|
||
query += " ORDER BY CT_Intitule"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
prospects = []
|
||
for row in rows:
|
||
prospects.append(
|
||
{
|
||
"numero": self._safe_strip(row.CT_Num),
|
||
"intitule": self._safe_strip(row.CT_Intitule),
|
||
"adresse": self._safe_strip(row.CT_Adresse),
|
||
"ville": self._safe_strip(row.CT_Ville),
|
||
"code_postal": self._safe_strip(row.CT_CodePostal),
|
||
"telephone": self._safe_strip(row.CT_Telephone),
|
||
"email": self._safe_strip(row.CT_EMail),
|
||
"type": 0,
|
||
"est_prospect": True,
|
||
}
|
||
)
|
||
|
||
logger.info(f"✅ SQL: {len(prospects)} prospects")
|
||
return prospects
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL prospects: {e}")
|
||
return []
|
||
|
||
def lire_prospect(self, code_prospect):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
CT_Num, CT_Intitule, CT_Type, CT_Qualite,
|
||
CT_Adresse, CT_Complement, CT_Ville, CT_CodePostal, CT_Pays,
|
||
CT_Telephone, CT_Portable, CT_EMail, CT_Telecopie,
|
||
CT_Siret, CT_Identifiant, CT_Sommeil, CT_Prospect,
|
||
CT_Contact, CT_FormeJuridique, CT_Secteur
|
||
FROM F_COMPTET
|
||
WHERE CT_Num = ? AND CT_Type = 0 AND CT_Prospect = 1
|
||
""",
|
||
(code_prospect.upper(),),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if not row:
|
||
return None
|
||
|
||
return {
|
||
"numero": self._safe_strip(row.CT_Num),
|
||
"intitule": self._safe_strip(row.CT_Intitule),
|
||
"type": 0,
|
||
"qualite": self._safe_strip(row.CT_Qualite),
|
||
"est_prospect": True,
|
||
"adresse": self._safe_strip(row.CT_Adresse),
|
||
"complement": self._safe_strip(row.CT_Complement),
|
||
"ville": self._safe_strip(row.CT_Ville),
|
||
"code_postal": self._safe_strip(row.CT_CodePostal),
|
||
"pays": self._safe_strip(row.CT_Pays),
|
||
"telephone": self._safe_strip(row.CT_Telephone),
|
||
"portable": self._safe_strip(row.CT_Portable),
|
||
"email": self._safe_strip(row.CT_EMail),
|
||
"telecopie": self._safe_strip(row.CT_Telecopie),
|
||
"siret": self._safe_strip(row.CT_Siret),
|
||
"tva_intra": self._safe_strip(row.CT_Identifiant),
|
||
"est_actif": (row.CT_Sommeil == 0),
|
||
"contact": self._safe_strip(row.CT_Contact),
|
||
"forme_juridique": self._safe_strip(row.CT_FormeJuridique),
|
||
"secteur": self._safe_strip(row.CT_Secteur),
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL prospect {code_prospect}: {e}")
|
||
return None
|
||
|
||
def lister_avoirs(self, limit=100, statut=None):
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
query = f"""
|
||
SELECT TOP ({limit})
|
||
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
|
||
d.DO_Statut, d.CT_Num, c.CT_Intitule
|
||
FROM F_DOCENTETE d
|
||
LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num
|
||
WHERE d.DO_Type = 50
|
||
"""
|
||
|
||
params = []
|
||
|
||
if statut is not None:
|
||
query += " AND d.DO_Statut = ?"
|
||
params.append(statut)
|
||
|
||
query += " ORDER BY d.DO_Date DESC"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
avoirs = []
|
||
for row in rows:
|
||
avoirs.append(
|
||
{
|
||
"numero": self._safe_strip(row.DO_Piece),
|
||
"reference": self._safe_strip(row.DO_Ref),
|
||
"date": str(row.DO_Date) if row.DO_Date else "",
|
||
"client_code": self._safe_strip(row.CT_Num),
|
||
"client_intitule": self._safe_strip(row.CT_Intitule),
|
||
"total_ht": (
|
||
float(row.DO_TotalHT) if row.DO_TotalHT else 0.0
|
||
),
|
||
"total_ttc": (
|
||
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
|
||
),
|
||
"statut": row.DO_Statut if row.DO_Statut is not None else 0,
|
||
}
|
||
)
|
||
|
||
return avoirs
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL avoirs: {e}")
|
||
return []
|
||
|
||
def lire_avoir(self, numero):
|
||
return self._lire_document_sql(numero, type_doc=50)
|
||
|
||
def lister_livraisons(self, limit=100, statut=None):
|
||
"""📖 Liste les livraisons via SQL (méthode legacy)"""
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
query = f"""
|
||
SELECT TOP ({limit})
|
||
d.DO_Piece, d.DO_Date, d.DO_Ref, d.DO_TotalHT, d.DO_TotalTTC,
|
||
d.DO_Statut, d.CT_Num, c.CT_Intitule
|
||
FROM F_DOCENTETE d
|
||
LEFT JOIN F_COMPTET c ON d.CT_Num = c.CT_Num
|
||
WHERE d.DO_Type = 30
|
||
"""
|
||
|
||
params = []
|
||
|
||
if statut is not None:
|
||
query += " AND d.DO_Statut = ?"
|
||
params.append(statut)
|
||
|
||
query += " ORDER BY d.DO_Date DESC"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
livraisons = []
|
||
for row in rows:
|
||
livraisons.append(
|
||
{
|
||
"numero": self._safe_strip(row.DO_Piece),
|
||
"reference": self._safe_strip(row.DO_Ref),
|
||
"date": str(row.DO_Date) if row.DO_Date else "",
|
||
"client_code": self._safe_strip(row.CT_Num),
|
||
"client_intitule": self._safe_strip(row.CT_Intitule),
|
||
"total_ht": (
|
||
float(row.DO_TotalHT) if row.DO_TotalHT else 0.0
|
||
),
|
||
"total_ttc": (
|
||
float(row.DO_TotalTTC) if row.DO_TotalTTC else 0.0
|
||
),
|
||
"statut": row.DO_Statut if row.DO_Statut is not None else 0,
|
||
}
|
||
)
|
||
|
||
return livraisons
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur SQL livraisons: {e}")
|
||
return []
|
||
|
||
def lire_livraison(self, numero):
|
||
"""📖 Lit UNE livraison via SQL (avec lignes)"""
|
||
return self._lire_document_sql(numero, type_doc=30)
|
||
|
||
def creer_client(self, client_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
# ========================================
|
||
# ÉTAPE 0 : VALIDATION & NETTOYAGE
|
||
# ========================================
|
||
logger.info("🔍 === VALIDATION DES DONNÉES ===")
|
||
|
||
if not client_data.get("intitule"):
|
||
raise ValueError("Le champ 'intitule' est obligatoire")
|
||
|
||
# Nettoyage et troncature
|
||
intitule = str(client_data["intitule"])[:69].strip()
|
||
num_prop = (
|
||
str(client_data.get("num", "")).upper()[:17].strip()
|
||
if client_data.get("num")
|
||
else ""
|
||
)
|
||
compte = str(client_data.get("compte_collectif", "411000"))[:13].strip()
|
||
|
||
adresse = str(client_data.get("adresse", ""))[:35].strip()
|
||
code_postal = str(client_data.get("code_postal", ""))[:9].strip()
|
||
ville = str(client_data.get("ville", ""))[:35].strip()
|
||
pays = str(client_data.get("pays", ""))[:35].strip()
|
||
|
||
telephone = str(client_data.get("telephone", ""))[:21].strip()
|
||
email = str(client_data.get("email", ""))[:69].strip()
|
||
|
||
siret = str(client_data.get("siret", ""))[:14].strip()
|
||
tva_intra = str(client_data.get("tva_intra", ""))[:25].strip()
|
||
|
||
logger.info(f" intitule: '{intitule}' (len={len(intitule)})")
|
||
logger.info(f" num: '{num_prop or 'AUTO'}' (len={len(num_prop)})")
|
||
logger.info(f" compte: '{compte}' (len={len(compte)})")
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : CRÉATION OBJET CLIENT
|
||
# ========================================
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
|
||
persist = factory_client.Create()
|
||
client = win32com.client.CastTo(persist, "IBOClient3")
|
||
|
||
# 🔑 CRITIQUE : Initialiser l'objet
|
||
client.SetDefault()
|
||
|
||
logger.info("✅ Objet client créé et initialisé")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : CHAMPS OBLIGATOIRES (dans l'ordre !)
|
||
# ========================================
|
||
logger.info("📝 Définition des champs obligatoires...")
|
||
|
||
# 1. Intitulé (OBLIGATOIRE)
|
||
client.CT_Intitule = intitule
|
||
logger.debug(f" ✅ CT_Intitule: '{intitule}'")
|
||
|
||
# ❌ CT_Type SUPPRIMÉ (n'existe pas dans cette version)
|
||
# client.CT_Type = 0 # <-- LIGNE SUPPRIMÉE
|
||
|
||
# 2. Qualité (important pour filtrage Client/Fournisseur)
|
||
try:
|
||
client.CT_Qualite = "CLI"
|
||
logger.debug(" ✅ CT_Qualite: 'CLI'")
|
||
except:
|
||
logger.debug(" ⚠️ CT_Qualite non défini (pas critique)")
|
||
|
||
# 3. Compte général principal (OBLIGATOIRE)
|
||
try:
|
||
factory_compte = self.cial.CptaApplication.FactoryCompteG
|
||
persist_compte = factory_compte.ReadNumero(compte)
|
||
|
||
if persist_compte:
|
||
compte_obj = win32com.client.CastTo(
|
||
persist_compte, "IBOCompteG3"
|
||
)
|
||
compte_obj.Read()
|
||
|
||
# Assigner l'objet CompteG
|
||
client.CompteGPrinc = compte_obj
|
||
logger.debug(f" ✅ CompteGPrinc: objet '{compte}' assigné")
|
||
else:
|
||
logger.warning(
|
||
f" ⚠️ Compte {compte} introuvable - utilisation du compte par défaut"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Erreur CompteGPrinc: {e}")
|
||
|
||
# 4. Numéro client (OBLIGATOIRE - générer si vide)
|
||
if num_prop:
|
||
client.CT_Num = num_prop
|
||
logger.debug(f" ✅ CT_Num fourni: '{num_prop}'")
|
||
else:
|
||
# 🔑 CRITIQUE : Générer le numéro automatiquement
|
||
try:
|
||
# Méthode 1 : Utiliser SetDefaultNumPiece (si disponible)
|
||
if hasattr(client, "SetDefaultNumPiece"):
|
||
client.SetDefaultNumPiece()
|
||
num_genere = getattr(client, "CT_Num", "")
|
||
logger.debug(
|
||
f" ✅ CT_Num auto-généré (SetDefaultNumPiece): '{num_genere}'"
|
||
)
|
||
else:
|
||
# Méthode 2 : Lire le prochain numéro depuis la souche
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
num_genere = factory_client.GetNextNumero()
|
||
if num_genere:
|
||
client.CT_Num = num_genere
|
||
logger.debug(
|
||
f" ✅ CT_Num auto-généré (GetNextNumero): '{num_genere}'"
|
||
)
|
||
else:
|
||
# Méthode 3 : Fallback - utiliser un timestamp
|
||
import time
|
||
|
||
num_genere = f"CLI{int(time.time()) % 1000000}"
|
||
client.CT_Num = num_genere
|
||
logger.warning(
|
||
f" ⚠️ CT_Num fallback temporaire: '{num_genere}'"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f" ❌ Impossible de générer CT_Num: {e}")
|
||
raise ValueError(
|
||
"Impossible de générer le numéro client automatiquement. Veuillez fournir un numéro manuellement."
|
||
)
|
||
|
||
# 5. Catégories tarifaires (valeurs par défaut)
|
||
try:
|
||
# Catégorie tarifaire (obligatoire)
|
||
if hasattr(client, "N_CatTarif"):
|
||
client.N_CatTarif = 1
|
||
|
||
# Catégorie comptable (obligatoire)
|
||
if hasattr(client, "N_CatCompta"):
|
||
client.N_CatCompta = 1
|
||
|
||
# Autres catégories
|
||
if hasattr(client, "N_Period"):
|
||
client.N_Period = 1
|
||
|
||
if hasattr(client, "N_Expedition"):
|
||
client.N_Expedition = 1
|
||
|
||
if hasattr(client, "N_Condition"):
|
||
client.N_Condition = 1
|
||
|
||
if hasattr(client, "N_Risque"):
|
||
client.N_Risque = 1
|
||
|
||
logger.debug(" ✅ Catégories (N_*) initialisées")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Catégories: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : CHAMPS OPTIONNELS MAIS RECOMMANDÉS
|
||
# ========================================
|
||
logger.info("📝 Définition champs optionnels...")
|
||
|
||
# Adresse (objet IAdresse)
|
||
if any([adresse, code_postal, ville, pays]):
|
||
try:
|
||
adresse_obj = client.Adresse
|
||
|
||
if adresse:
|
||
adresse_obj.Adresse = adresse
|
||
if code_postal:
|
||
adresse_obj.CodePostal = code_postal
|
||
if ville:
|
||
adresse_obj.Ville = ville
|
||
if pays:
|
||
adresse_obj.Pays = pays
|
||
|
||
logger.debug(" ✅ Adresse définie")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Adresse: {e}")
|
||
|
||
# Télécom (objet ITelecom)
|
||
if telephone or email:
|
||
try:
|
||
telecom_obj = client.Telecom
|
||
|
||
if telephone:
|
||
telecom_obj.Telephone = telephone
|
||
if email:
|
||
telecom_obj.EMail = email
|
||
|
||
logger.debug(" ✅ Télécom défini")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Télécom: {e}")
|
||
|
||
# Identifiants fiscaux
|
||
if siret:
|
||
try:
|
||
client.CT_Siret = siret
|
||
logger.debug(f" ✅ SIRET: '{siret}'")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ SIRET: {e}")
|
||
|
||
if tva_intra:
|
||
try:
|
||
client.CT_Identifiant = tva_intra
|
||
logger.debug(f" ✅ TVA intracommunautaire: '{tva_intra}'")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ TVA: {e}")
|
||
|
||
# Autres champs utiles (valeurs par défaut intelligentes)
|
||
try:
|
||
# Type de facturation (1 = facture normale)
|
||
if hasattr(client, "CT_Facture"):
|
||
client.CT_Facture = 1
|
||
|
||
# Lettrage automatique activé
|
||
if hasattr(client, "CT_Lettrage"):
|
||
client.CT_Lettrage = True
|
||
|
||
# Pas de prospect
|
||
if hasattr(client, "CT_Prospect"):
|
||
client.CT_Prospect = False
|
||
|
||
# Client actif (pas en sommeil)
|
||
if hasattr(client, "CT_Sommeil"):
|
||
client.CT_Sommeil = False
|
||
|
||
logger.debug(" ✅ Options par défaut définies")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ Options: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : DIAGNOSTIC PRÉ-WRITE (pour debug)
|
||
# ========================================
|
||
logger.info("🔍 === DIAGNOSTIC PRÉ-WRITE ===")
|
||
|
||
champs_critiques = [
|
||
("CT_Intitule", "str"),
|
||
("CT_Num", "str"),
|
||
("CompteGPrinc", "object"),
|
||
("N_CatTarif", "int"),
|
||
("N_CatCompta", "int"),
|
||
]
|
||
|
||
for champ, type_attendu in champs_critiques:
|
||
try:
|
||
val = getattr(client, champ, None)
|
||
|
||
if type_attendu == "object":
|
||
status = "✅ Objet défini" if val else "❌ NULL"
|
||
else:
|
||
if type_attendu == "str":
|
||
status = (
|
||
f"✅ '{val}' (len={len(val)})" if val else "❌ Vide"
|
||
)
|
||
else:
|
||
status = f"✅ {val}"
|
||
|
||
logger.info(f" {champ}: {status}")
|
||
except Exception as e:
|
||
logger.error(f" {champ}: ❌ Erreur - {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 5 : VÉRIFICATION FINALE CT_Num
|
||
# ========================================
|
||
num_avant_write = getattr(client, "CT_Num", "")
|
||
if not num_avant_write:
|
||
logger.error(
|
||
"❌ CRITIQUE: CT_Num toujours vide après toutes les tentatives !"
|
||
)
|
||
raise ValueError(
|
||
"Le numéro client (CT_Num) est obligatoire mais n'a pas pu être généré. "
|
||
"Veuillez fournir un numéro manuellement via le paramètre 'num'."
|
||
)
|
||
|
||
logger.info(f"✅ CT_Num confirmé avant Write(): '{num_avant_write}'")
|
||
|
||
# ========================================
|
||
# ÉTAPE 6 : ÉCRITURE EN BASE
|
||
# ========================================
|
||
logger.info("💾 Écriture du client dans Sage...")
|
||
|
||
try:
|
||
client.Write()
|
||
logger.info("✅ Write() réussi !")
|
||
|
||
except Exception as e:
|
||
error_detail = str(e)
|
||
|
||
# Récupérer l'erreur Sage détaillée
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_detail = (
|
||
f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
)
|
||
logger.error(f"❌ Erreur Sage: {error_detail}")
|
||
except:
|
||
pass
|
||
|
||
# Analyser l'erreur spécifique
|
||
if "longueur invalide" in error_detail.lower():
|
||
logger.error("❌ ERREUR 'longueur invalide' - Dump des champs:")
|
||
|
||
for attr in dir(client):
|
||
if attr.startswith("CT_") or attr.startswith("N_"):
|
||
try:
|
||
val = getattr(client, attr, None)
|
||
if isinstance(val, str):
|
||
logger.error(
|
||
f" {attr}: '{val}' (len={len(val)})"
|
||
)
|
||
elif val is not None and not callable(val):
|
||
logger.error(
|
||
f" {attr}: {val} (type={type(val).__name__})"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
if (
|
||
"doublon" in error_detail.lower()
|
||
or "existe" in error_detail.lower()
|
||
):
|
||
raise ValueError(f"Ce client existe déjà : {error_detail}")
|
||
|
||
raise RuntimeError(f"Échec Write(): {error_detail}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 7 : RELECTURE & FINALISATION
|
||
# ========================================
|
||
try:
|
||
client.Read()
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Impossible de relire: {e}")
|
||
|
||
num_final = getattr(client, "CT_Num", "")
|
||
|
||
if not num_final:
|
||
raise RuntimeError("CT_Num vide après Write()")
|
||
|
||
logger.info(f"✅✅✅ CLIENT CRÉÉ: {num_final} - {intitule} ✅✅✅")
|
||
|
||
# ========================================
|
||
# ÉTAPE 8 : REFRESH CACHE
|
||
# ========================================
|
||
|
||
return {
|
||
"numero": num_final,
|
||
"intitule": intitule,
|
||
"compte_collectif": compte,
|
||
"type": 0, # Par défaut client
|
||
"adresse": adresse or None,
|
||
"code_postal": code_postal or None,
|
||
"ville": ville or None,
|
||
"pays": pays or None,
|
||
"email": email or None,
|
||
"telephone": telephone or None,
|
||
"siret": siret or None,
|
||
"tva_intra": tva_intra or None,
|
||
}
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur création client: {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = f"Erreur Sage: {err.Description}"
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur technique Sage: {error_message}")
|
||
|
||
def modifier_client(self, code: str, client_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
# ========================================
|
||
# ÉTAPE 1 : CHARGER LE CLIENT EXISTANT
|
||
# ========================================
|
||
logger.info(f"🔍 Recherche client {code}...")
|
||
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist = factory_client.ReadNumero(code)
|
||
|
||
if not persist:
|
||
raise ValueError(f"Client {code} introuvable")
|
||
|
||
client = win32com.client.CastTo(persist, "IBOClient3")
|
||
client.Read()
|
||
|
||
logger.info(
|
||
f"✅ Client {code} trouvé: {getattr(client, 'CT_Intitule', '')}"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : METTRE À JOUR LES CHAMPS FOURNIS
|
||
# ========================================
|
||
logger.info("📝 Mise à jour des champs...")
|
||
|
||
champs_modifies = []
|
||
|
||
# Intitulé
|
||
if "intitule" in client_data:
|
||
intitule = str(client_data["intitule"])[:69].strip()
|
||
client.CT_Intitule = intitule
|
||
champs_modifies.append(f"intitule='{intitule}'")
|
||
|
||
# Adresse
|
||
if any(
|
||
k in client_data
|
||
for k in ["adresse", "code_postal", "ville", "pays"]
|
||
):
|
||
try:
|
||
adresse_obj = client.Adresse
|
||
|
||
if "adresse" in client_data:
|
||
adresse = str(client_data["adresse"])[:35].strip()
|
||
adresse_obj.Adresse = adresse
|
||
champs_modifies.append("adresse")
|
||
|
||
if "code_postal" in client_data:
|
||
cp = str(client_data["code_postal"])[:9].strip()
|
||
adresse_obj.CodePostal = cp
|
||
champs_modifies.append("code_postal")
|
||
|
||
if "ville" in client_data:
|
||
ville = str(client_data["ville"])[:35].strip()
|
||
adresse_obj.Ville = ville
|
||
champs_modifies.append("ville")
|
||
|
||
if "pays" in client_data:
|
||
pays = str(client_data["pays"])[:35].strip()
|
||
adresse_obj.Pays = pays
|
||
champs_modifies.append("pays")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour adresse: {e}")
|
||
|
||
# Télécom
|
||
if "email" in client_data or "telephone" in client_data:
|
||
try:
|
||
telecom_obj = client.Telecom
|
||
|
||
if "email" in client_data:
|
||
email = str(client_data["email"])[:69].strip()
|
||
telecom_obj.EMail = email
|
||
champs_modifies.append("email")
|
||
|
||
if "telephone" in client_data:
|
||
tel = str(client_data["telephone"])[:21].strip()
|
||
telecom_obj.Telephone = tel
|
||
champs_modifies.append("telephone")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour télécom: {e}")
|
||
|
||
# SIRET
|
||
if "siret" in client_data:
|
||
try:
|
||
siret = str(client_data["siret"])[:14].strip()
|
||
client.CT_Siret = siret
|
||
champs_modifies.append("siret")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour SIRET: {e}")
|
||
|
||
# TVA Intracommunautaire
|
||
if "tva_intra" in client_data:
|
||
try:
|
||
tva = str(client_data["tva_intra"])[:25].strip()
|
||
client.CT_Identifiant = tva
|
||
champs_modifies.append("tva_intra")
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Erreur mise à jour TVA: {e}")
|
||
|
||
if not champs_modifies:
|
||
logger.warning("⚠️ Aucun champ à modifier")
|
||
return self._extraire_client(client)
|
||
|
||
logger.info(f"📝 Champs à modifier: {', '.join(champs_modifies)}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : ÉCRIRE LES MODIFICATIONS
|
||
# ========================================
|
||
logger.info("💾 Écriture des modifications...")
|
||
|
||
try:
|
||
client.Write()
|
||
logger.info("✅ Write() réussi !")
|
||
|
||
except Exception as e:
|
||
error_detail = str(e)
|
||
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_detail = (
|
||
f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
logger.error(f"❌ Erreur Write(): {error_detail}")
|
||
raise RuntimeError(f"Échec modification: {error_detail}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : RELIRE ET RETOURNER
|
||
# ========================================
|
||
client.Read()
|
||
|
||
logger.info(
|
||
f"✅✅✅ CLIENT MODIFIÉ: {code} ({len(champs_modifies)} champs) ✅✅✅"
|
||
)
|
||
|
||
# Refresh cache
|
||
|
||
return self._extraire_client(client)
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur modification client: {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = f"Erreur Sage: {err.Description}"
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur technique Sage: {error_message}")
|
||
|
||
def modifier_devis(self, numero: str, devis_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
# ÉTAPE 1 : CHARGER LE DEVIS
|
||
logger.info(f"🔍 Recherche devis {numero}...")
|
||
|
||
factory = self.cial.FactoryDocumentVente
|
||
persist = factory.ReadPiece(0, numero)
|
||
|
||
if not persist:
|
||
index = 1
|
||
while index < 10000:
|
||
try:
|
||
persist_test = factory.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
doc_test = win32com.client.CastTo(
|
||
persist_test, "IBODocumentVente3"
|
||
)
|
||
doc_test.Read()
|
||
|
||
if (
|
||
getattr(doc_test, "DO_Type", -1) == 0
|
||
and getattr(doc_test, "DO_Piece", "") == numero
|
||
):
|
||
persist = persist_test
|
||
break
|
||
|
||
index += 1
|
||
except:
|
||
index += 1
|
||
|
||
if not persist:
|
||
raise ValueError(f"Devis {numero} introuvable")
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
logger.info(f"✅ Devis {numero} trouvé")
|
||
|
||
# Vérifier transformation
|
||
verification = self.verifier_si_deja_transforme(numero, 0)
|
||
|
||
if verification["deja_transforme"]:
|
||
docs_cibles = verification["documents_cibles"]
|
||
nums = [d["numero"] for d in docs_cibles]
|
||
raise ValueError(
|
||
f"❌ Devis {numero} déjà transformé en {len(docs_cibles)} document(s): {', '.join(nums)}"
|
||
)
|
||
|
||
statut_actuel = getattr(doc, "DO_Statut", 0)
|
||
if statut_actuel == 5:
|
||
raise ValueError(f"Devis {numero} déjà transformé (statut=5)")
|
||
|
||
# ÉTAPE 2 : CHAMPS SIMPLES
|
||
champs_modifies = []
|
||
|
||
if "date_devis" in devis_data:
|
||
import pywintypes
|
||
|
||
date_str = devis_data["date_devis"]
|
||
date_obj = (
|
||
datetime.fromisoformat(date_str)
|
||
if isinstance(date_str, str)
|
||
else date_str
|
||
)
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
champs_modifies.append("date")
|
||
logger.info(f"📅 Date: {date_obj.date()}")
|
||
|
||
if "statut" in devis_data:
|
||
nouveau_statut = devis_data["statut"]
|
||
doc.DO_Statut = nouveau_statut
|
||
champs_modifies.append("statut")
|
||
logger.info(f"📊 Statut: {statut_actuel} → {nouveau_statut}")
|
||
|
||
if champs_modifies:
|
||
doc.Write()
|
||
|
||
# ÉTAPE 3 : MODIFICATION INTELLIGENTE DES LIGNES
|
||
if "lignes" in devis_data and devis_data["lignes"] is not None:
|
||
logger.info(f"🔄 Modification intelligente des lignes...")
|
||
|
||
nouvelles_lignes = devis_data["lignes"]
|
||
nb_nouvelles = len(nouvelles_lignes)
|
||
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
# Compter existantes
|
||
nb_existantes = 0
|
||
index = 1
|
||
while index <= 100:
|
||
try:
|
||
ligne_p = factory_lignes.List(index)
|
||
if ligne_p is None:
|
||
break
|
||
nb_existantes += 1
|
||
index += 1
|
||
except:
|
||
break
|
||
|
||
logger.info(
|
||
f"📊 {nb_existantes} existantes → {nb_nouvelles} nouvelles"
|
||
)
|
||
|
||
# MODIFIER EXISTANTES
|
||
nb_a_modifier = min(nb_existantes, nb_nouvelles)
|
||
|
||
for idx in range(1, nb_a_modifier + 1):
|
||
ligne_data = nouvelles_lignes[idx - 1]
|
||
|
||
ligne_p = factory_lignes.List(idx)
|
||
ligne = win32com.client.CastTo(ligne_p, "IBODocumentLigne3")
|
||
ligne.Read()
|
||
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"Article {ligne_data['article_code']} introuvable"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
try:
|
||
ligne.WriteDefault()
|
||
except:
|
||
pass
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
except:
|
||
try:
|
||
ligne.SetDefaultArticle(article_obj, quantite)
|
||
except:
|
||
ligne.DL_Design = ligne_data.get("designation", "")
|
||
ligne.DL_Qte = quantite
|
||
|
||
if ligne_data.get("prix_unitaire_ht"):
|
||
ligne.DL_PrixUnitaire = float(
|
||
ligne_data["prix_unitaire_ht"]
|
||
)
|
||
|
||
if ligne_data.get("remise_pourcentage", 0) > 0:
|
||
try:
|
||
ligne.DL_Remise01REM_Valeur = float(
|
||
ligne_data["remise_pourcentage"]
|
||
)
|
||
ligne.DL_Remise01REM_Type = 0
|
||
except:
|
||
pass
|
||
|
||
ligne.Write()
|
||
logger.debug(f" ✅ Ligne {idx} modifiée")
|
||
|
||
# AJOUTER MANQUANTES
|
||
if nb_nouvelles > nb_existantes:
|
||
for idx in range(nb_existantes, nb_nouvelles):
|
||
ligne_data = nouvelles_lignes[idx]
|
||
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"Article {ligne_data['article_code']} introuvable"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
except:
|
||
ligne_obj.DL_Design = ligne_data.get(
|
||
"designation", ""
|
||
)
|
||
ligne_obj.DL_Qte = quantite
|
||
|
||
if ligne_data.get("prix_unitaire_ht"):
|
||
ligne_obj.DL_PrixUnitaire = float(
|
||
ligne_data["prix_unitaire_ht"]
|
||
)
|
||
|
||
if ligne_data.get("remise_pourcentage", 0) > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(
|
||
ligne_data["remise_pourcentage"]
|
||
)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
except:
|
||
pass
|
||
|
||
ligne_obj.Write()
|
||
logger.debug(f" ✅ Ligne {idx + 1} ajoutée")
|
||
|
||
# SUPPRIMER EN TROP
|
||
elif nb_nouvelles < nb_existantes:
|
||
for idx in range(nb_existantes, nb_nouvelles, -1):
|
||
try:
|
||
ligne_p = factory_lignes.List(idx)
|
||
if ligne_p:
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
ligne.Read()
|
||
|
||
try:
|
||
ligne.Remove()
|
||
except AttributeError:
|
||
ligne.WriteDefault()
|
||
except:
|
||
pass
|
||
except:
|
||
pass
|
||
|
||
champs_modifies.append("lignes")
|
||
|
||
# VALIDATION
|
||
logger.info("💾 Validation finale...")
|
||
doc.Write()
|
||
|
||
import time
|
||
|
||
time.sleep(1)
|
||
|
||
doc.Read()
|
||
|
||
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
|
||
|
||
logger.info(f"✅✅✅ DEVIS MODIFIÉ: {numero} ✅✅✅")
|
||
logger.info(f"💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC")
|
||
|
||
return {
|
||
"numero": numero,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"champs_modifies": champs_modifies,
|
||
"statut": getattr(doc, "DO_Statut", 0),
|
||
}
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur technique: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur technique Sage: {str(e)}")
|
||
|
||
def creer_commande_enrichi(self, commande_data: dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
logger.info(
|
||
f"🚀 Début création commande pour client {commande_data['client']['code']}"
|
||
)
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("✅ Transaction Sage démarrée")
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
# Création document COMMANDE (type 10)
|
||
process = self.cial.CreateProcess_Document(
|
||
settings.SAGE_TYPE_BON_COMMANDE
|
||
)
|
||
doc = process.Document
|
||
|
||
try:
|
||
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
|
||
except:
|
||
pass
|
||
|
||
logger.info("📄 Document commande créé")
|
||
|
||
# Date
|
||
import pywintypes
|
||
|
||
if isinstance(commande_data["date_commande"], str):
|
||
date_obj = datetime.fromisoformat(
|
||
commande_data["date_commande"]
|
||
)
|
||
elif isinstance(commande_data["date_commande"], date):
|
||
date_obj = datetime.combine(
|
||
commande_data["date_commande"], datetime.min.time()
|
||
)
|
||
else:
|
||
date_obj = datetime.now()
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
logger.info(f"📅 Date définie: {date_obj.date()}")
|
||
|
||
# Client (CRITIQUE)
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist_client = factory_client.ReadNumero(
|
||
commande_data["client"]["code"]
|
||
)
|
||
|
||
if not persist_client:
|
||
raise ValueError(
|
||
f"Client {commande_data['client']['code']} introuvable"
|
||
)
|
||
|
||
client_obj = self._cast_client(persist_client)
|
||
if not client_obj:
|
||
raise ValueError(f"Impossible de charger le client")
|
||
|
||
doc.SetDefaultClient(client_obj)
|
||
doc.Write()
|
||
logger.info(f"👤 Client {commande_data['client']['code']} associé")
|
||
|
||
# Référence externe (optionnelle)
|
||
if commande_data.get("reference"):
|
||
try:
|
||
doc.DO_Ref = commande_data["reference"]
|
||
logger.info(f"📖 Référence: {commande_data['reference']}")
|
||
except:
|
||
pass
|
||
|
||
# Lignes
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
logger.info(f"📦 Ajout de {len(commande_data['lignes'])} lignes...")
|
||
|
||
for idx, ligne_data in enumerate(commande_data["lignes"], 1):
|
||
logger.info(
|
||
f"--- Ligne {idx}: {ligne_data['article_code']} ---"
|
||
)
|
||
|
||
# 📍 ÉTAPE 1: Charger l'article RÉEL depuis Sage
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"❌ Article {ligne_data['article_code']} introuvable dans Sage"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
# 💰 ÉTAPE 2: Récupérer le prix de vente RÉEL
|
||
prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0))
|
||
designation_sage = getattr(article_obj, "AR_Design", "")
|
||
logger.info(f"💰 Prix Sage: {prix_sage}€")
|
||
|
||
# ✅ TOLÉRER prix = 0 (articles de service, etc.)
|
||
if prix_sage == 0:
|
||
logger.warning(
|
||
f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)"
|
||
)
|
||
|
||
# 📍 ÉTAPE 3: Créer la ligne
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
# ✅ SetDefaultArticleReference
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
logger.info(
|
||
f"✅ Article associé via SetDefaultArticleReference"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet"
|
||
)
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
logger.info(f"✅ Article associé via SetDefaultArticle")
|
||
except Exception as e2:
|
||
logger.error(f"❌ Toutes les méthodes ont échoué")
|
||
ligne_obj.DL_Design = (
|
||
designation_sage
|
||
or ligne_data.get("designation", "")
|
||
)
|
||
ligne_obj.DL_Qte = quantite
|
||
logger.warning("⚠️ Configuration manuelle appliquée")
|
||
|
||
# ⚙️ ÉTAPE 4: Vérifier le prix automatique
|
||
prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
logger.info(f"💰 Prix auto chargé: {prix_auto}€")
|
||
|
||
# 💵 ÉTAPE 5: Ajuster le prix si nécessaire
|
||
prix_a_utiliser = ligne_data.get("prix_unitaire_ht")
|
||
|
||
if prix_a_utiliser is not None and prix_a_utiliser > 0:
|
||
# Prix personnalisé fourni
|
||
ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser)
|
||
logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€")
|
||
elif prix_auto == 0 and prix_sage > 0:
|
||
# Pas de prix auto mais prix Sage existe
|
||
ligne_obj.DL_PrixUnitaire = float(prix_sage)
|
||
logger.info(f"💰 Prix Sage forcé: {prix_sage}€")
|
||
elif prix_auto > 0:
|
||
# Prix auto OK
|
||
logger.info(f"💰 Prix auto conservé: {prix_auto}€")
|
||
# ✅ SINON: Prix reste à 0 (toléré pour services, etc.)
|
||
|
||
prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
montant_ligne = quantite * prix_final
|
||
logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€")
|
||
|
||
# 🎁 Remise
|
||
remise = ligne_data.get("remise_pourcentage", 0)
|
||
if remise > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(remise)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
montant_apres_remise = montant_ligne * (
|
||
1 - remise / 100
|
||
)
|
||
logger.info(
|
||
f"🎁 Remise {remise}% → {montant_apres_remise}€"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Remise non appliquée: {e}")
|
||
|
||
# 💾 ÉTAPE 6: Écrire la ligne
|
||
ligne_obj.Write()
|
||
logger.info(f"✅ Ligne {idx} écrite")
|
||
|
||
# 🔍 VÉRIFICATION
|
||
try:
|
||
ligne_obj.Read()
|
||
prix_enregistre = float(
|
||
getattr(ligne_obj, "DL_PrixUnitaire", 0.0)
|
||
)
|
||
montant_enregistre = float(
|
||
getattr(ligne_obj, "DL_MontantHT", 0.0)
|
||
)
|
||
logger.info(
|
||
f"🔍 Vérif: Prix={prix_enregistre}€, Montant HT={montant_enregistre}€"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Impossible de vérifier: {e}")
|
||
|
||
# Validation
|
||
doc.Write()
|
||
process.Process()
|
||
|
||
if transaction_active:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
|
||
# Récupération numéro
|
||
time.sleep(2)
|
||
|
||
numero_commande = None
|
||
try:
|
||
doc_result = process.DocumentResult
|
||
if doc_result:
|
||
doc_result = win32com.client.CastTo(
|
||
doc_result, "IBODocumentVente3"
|
||
)
|
||
doc_result.Read()
|
||
numero_commande = getattr(doc_result, "DO_Piece", "")
|
||
except:
|
||
pass
|
||
|
||
if not numero_commande:
|
||
numero_commande = getattr(doc, "DO_Piece", "")
|
||
|
||
# Relecture
|
||
factory_doc = self.cial.FactoryDocumentVente
|
||
persist_reread = factory_doc.ReadPiece(
|
||
settings.SAGE_TYPE_BON_COMMANDE, numero_commande
|
||
)
|
||
|
||
if persist_reread:
|
||
doc_final = win32com.client.CastTo(
|
||
persist_reread, "IBODocumentVente3"
|
||
)
|
||
doc_final.Read()
|
||
|
||
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
|
||
else:
|
||
total_ht = 0.0
|
||
total_ttc = 0.0
|
||
|
||
logger.info(
|
||
f"✅✅✅ COMMANDE CRÉÉE: {numero_commande} - {total_ttc}€ TTC ✅✅✅"
|
||
)
|
||
|
||
return {
|
||
"numero_commande": numero_commande,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"nb_lignes": len(commande_data["lignes"]),
|
||
"client_code": commande_data["client"]["code"],
|
||
"date_commande": str(date_obj.date()),
|
||
}
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur création commande: {e}", exc_info=True)
|
||
raise RuntimeError(f"Échec création commande: {str(e)}")
|
||
|
||
def modifier_commande(self, numero: str, commande_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"🔬 === MODIFICATION COMMANDE {numero} ===")
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : CHARGER LE DOCUMENT
|
||
# ========================================
|
||
logger.info("📂 Chargement document...")
|
||
|
||
factory = self.cial.FactoryDocumentVente
|
||
persist = None
|
||
|
||
# Chercher le document
|
||
for type_test in [10, 3, settings.SAGE_TYPE_BON_COMMANDE]:
|
||
try:
|
||
persist_test = factory.ReadPiece(type_test, numero)
|
||
if persist_test:
|
||
persist = persist_test
|
||
logger.info(f" ✅ Document trouvé (type={type_test})")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not persist:
|
||
raise ValueError(f"❌ Commande {numero} INTROUVABLE")
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
statut_actuel = getattr(doc, "DO_Statut", 0)
|
||
type_reel = getattr(doc, "DO_Type", -1)
|
||
|
||
logger.info(f" 📊 Type={type_reel}, Statut={statut_actuel}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : VÉRIFIER CLIENT INITIAL
|
||
# ========================================
|
||
client_code_initial = ""
|
||
try:
|
||
client_obj = getattr(doc, "Client", None)
|
||
if client_obj:
|
||
client_obj.Read()
|
||
client_code_initial = getattr(client_obj, "CT_Num", "").strip()
|
||
logger.info(f" 👤 Client initial: {client_code_initial}")
|
||
else:
|
||
logger.error(" ❌ Objet Client NULL à l'état initial !")
|
||
except Exception as e:
|
||
logger.error(f" ❌ Erreur lecture client initial: {e}")
|
||
|
||
if not client_code_initial:
|
||
raise ValueError("❌ Client introuvable dans le document")
|
||
|
||
# Compter les lignes initiales
|
||
nb_lignes_initial = 0
|
||
try:
|
||
factory_lignes = getattr(
|
||
doc, "FactoryDocumentLigne", None
|
||
) or getattr(doc, "FactoryDocumentVenteLigne", None)
|
||
index = 1
|
||
while index <= 100:
|
||
try:
|
||
ligne_p = factory_lignes.List(index)
|
||
if ligne_p is None:
|
||
break
|
||
nb_lignes_initial += 1
|
||
index += 1
|
||
except:
|
||
break
|
||
|
||
logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Erreur comptage lignes: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : DÉTERMINER LES MODIFICATIONS
|
||
# ========================================
|
||
champs_modifies = []
|
||
|
||
modif_date = "date_commande" in commande_data
|
||
modif_statut = "statut" in commande_data
|
||
modif_ref = "reference" in commande_data
|
||
modif_lignes = (
|
||
"lignes" in commande_data and commande_data["lignes"] is not None
|
||
)
|
||
|
||
logger.info(f"📋 Modifications demandées:")
|
||
logger.info(f" Date: {modif_date}")
|
||
logger.info(f" Statut: {modif_statut}")
|
||
logger.info(f" Référence: {modif_ref}")
|
||
logger.info(f" Lignes: {modif_lignes}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : TEST WRITE() BASIQUE
|
||
# ========================================
|
||
logger.info("🧪 Test Write() basique (sans modification)...")
|
||
|
||
try:
|
||
doc.Write()
|
||
logger.info(" ✅ Write() basique OK")
|
||
doc.Read()
|
||
|
||
# Vérifier que le client est toujours là
|
||
client_obj = getattr(doc, "Client", None)
|
||
if client_obj:
|
||
client_obj.Read()
|
||
client_apres = getattr(client_obj, "CT_Num", "")
|
||
if client_apres == client_code_initial:
|
||
logger.info(f" ✅ Client préservé: {client_apres}")
|
||
else:
|
||
logger.error(
|
||
f" ❌ Client a changé: {client_code_initial} → {client_apres}"
|
||
)
|
||
else:
|
||
logger.error(" ❌ Client devenu NULL après Write() basique")
|
||
|
||
except Exception as e:
|
||
logger.error(f" ❌ Write() basique ÉCHOUE: {e}")
|
||
logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ")
|
||
raise ValueError(
|
||
f"Document verrouillé, impossible de modifier: {e}"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 5 : MODIFICATIONS SIMPLES (pas de lignes)
|
||
# ========================================
|
||
if not modif_lignes and (modif_date or modif_statut or modif_ref):
|
||
logger.info("🎯 Modifications simples (sans lignes)...")
|
||
|
||
if modif_date:
|
||
logger.info(" 📅 Modification date...")
|
||
import pywintypes
|
||
|
||
date_str = commande_data["date_commande"]
|
||
|
||
if isinstance(date_str, str):
|
||
date_obj = datetime.fromisoformat(date_str)
|
||
elif isinstance(date_str, date):
|
||
date_obj = datetime.combine(date_str, datetime.min.time())
|
||
else:
|
||
date_obj = date_str
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
champs_modifies.append("date")
|
||
logger.info(f" ✅ Date définie: {date_obj.date()}")
|
||
|
||
if modif_statut:
|
||
logger.info(" 📊 Modification statut...")
|
||
nouveau_statut = commande_data["statut"]
|
||
doc.DO_Statut = nouveau_statut
|
||
champs_modifies.append("statut")
|
||
logger.info(f" ✅ Statut défini: {nouveau_statut}")
|
||
|
||
if modif_ref:
|
||
logger.info(" 📖 Modification référence...")
|
||
try:
|
||
doc.DO_Ref = commande_data["reference"]
|
||
champs_modifies.append("reference")
|
||
logger.info(
|
||
f" ✅ Référence définie: {commande_data['reference']}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Référence non définie: {e}")
|
||
|
||
# Écrire sans réassocier le client
|
||
logger.info(" 💾 Write() sans réassociation client...")
|
||
try:
|
||
doc.Write()
|
||
logger.info(" ✅ Write() réussi")
|
||
|
||
doc.Read()
|
||
|
||
# Vérifier client
|
||
client_obj = getattr(doc, "Client", None)
|
||
if client_obj:
|
||
client_obj.Read()
|
||
client_apres = getattr(client_obj, "CT_Num", "")
|
||
if client_apres == client_code_initial:
|
||
logger.info(f" ✅ Client préservé: {client_apres}")
|
||
else:
|
||
logger.error(
|
||
f" ❌ Client perdu: {client_code_initial} → {client_apres}"
|
||
)
|
||
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_msg = f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
except:
|
||
pass
|
||
|
||
logger.error(f" ❌ Write() échoue: {error_msg}")
|
||
raise ValueError(f"Sage refuse: {error_msg}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 6 : REMPLACEMENT COMPLET DES LIGNES
|
||
# ========================================
|
||
elif modif_lignes:
|
||
logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...")
|
||
|
||
nouvelles_lignes = commande_data["lignes"]
|
||
nb_nouvelles = len(nouvelles_lignes)
|
||
|
||
logger.info(
|
||
f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles lignes"
|
||
)
|
||
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
# ============================================
|
||
# SOUS-ÉTAPE 1 : SUPPRIMER TOUTES LES LIGNES EXISTANTES
|
||
# ============================================
|
||
if nb_lignes_initial > 0:
|
||
logger.info(
|
||
f" 🗑️ Suppression de {nb_lignes_initial} lignes existantes..."
|
||
)
|
||
|
||
# Supprimer depuis la fin pour éviter les problèmes d'index
|
||
for idx in range(nb_lignes_initial, 0, -1):
|
||
try:
|
||
ligne_p = factory_lignes.List(idx)
|
||
if ligne_p:
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
ligne.Read()
|
||
|
||
# ✅ Utiliser .Remove() comme indiqué
|
||
ligne.Remove()
|
||
logger.debug(f" ✅ Ligne {idx} supprimée")
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" ⚠️ Impossible de supprimer ligne {idx}: {e}"
|
||
)
|
||
# Continuer même si une suppression échoue
|
||
|
||
logger.info(" ✅ Toutes les lignes existantes supprimées")
|
||
|
||
# ============================================
|
||
# SOUS-ÉTAPE 2 : AJOUTER LES NOUVELLES LIGNES
|
||
# ============================================
|
||
logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...")
|
||
|
||
for idx, ligne_data in enumerate(nouvelles_lignes, 1):
|
||
logger.info(
|
||
f" --- Ligne {idx}/{nb_nouvelles}: {ligne_data['article_code']} ---"
|
||
)
|
||
|
||
# Charger l'article
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"Article {ligne_data['article_code']} introuvable"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
# Créer nouvelle ligne
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
# Associer article
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
except:
|
||
ligne_obj.DL_Design = ligne_data.get("designation", "")
|
||
ligne_obj.DL_Qte = quantite
|
||
|
||
# Prix
|
||
if ligne_data.get("prix_unitaire_ht"):
|
||
ligne_obj.DL_PrixUnitaire = float(
|
||
ligne_data["prix_unitaire_ht"]
|
||
)
|
||
|
||
# Remise
|
||
if ligne_data.get("remise_pourcentage", 0) > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(
|
||
ligne_data["remise_pourcentage"]
|
||
)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
except:
|
||
pass
|
||
|
||
# Écrire la ligne
|
||
ligne_obj.Write()
|
||
logger.info(f" ✅ Ligne {idx} ajoutée")
|
||
|
||
logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées")
|
||
|
||
# Écrire le document
|
||
logger.info(" 💾 Write() document après remplacement lignes...")
|
||
doc.Write()
|
||
logger.info(" ✅ Document écrit")
|
||
|
||
doc.Read()
|
||
|
||
# Vérifier client
|
||
client_obj = getattr(doc, "Client", None)
|
||
if client_obj:
|
||
client_obj.Read()
|
||
client_apres = getattr(client_obj, "CT_Num", "")
|
||
logger.info(f" 👤 Client après remplacement: {client_apres}")
|
||
else:
|
||
logger.error(" ❌ Client NULL après remplacement")
|
||
|
||
champs_modifies.append("lignes")
|
||
|
||
# ========================================
|
||
# ÉTAPE 7 : RELECTURE ET RETOUR
|
||
# ========================================
|
||
logger.info("📊 Relecture finale...")
|
||
|
||
import time
|
||
|
||
time.sleep(1)
|
||
|
||
doc.Read()
|
||
|
||
# Vérifier client final
|
||
client_obj_final = getattr(doc, "Client", None)
|
||
if client_obj_final:
|
||
client_obj_final.Read()
|
||
client_final = getattr(client_obj_final, "CT_Num", "")
|
||
else:
|
||
client_final = ""
|
||
|
||
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
|
||
|
||
logger.info(f"✅ ✅ ✅ SUCCÈS: {numero} modifiée ✅ ✅ ✅")
|
||
logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC")
|
||
logger.info(f" 👤 Client final: {client_final}")
|
||
logger.info(f" 📝 Champs modifiés: {champs_modifies}")
|
||
|
||
return {
|
||
"numero": numero,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"champs_modifies": champs_modifies,
|
||
"statut": getattr(doc, "DO_Statut", 0),
|
||
"client_code": client_final,
|
||
}
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ ERREUR MÉTIER: {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ ERREUR TECHNIQUE: {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = (
|
||
f"Erreur Sage: {err.Description} (Code: {err.Number})"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur Sage: {error_message}")
|
||
|
||
def creer_livraison_enrichi(self, livraison_data: dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
logger.info(
|
||
f"🚀 Début création livraison pour client {livraison_data['client']['code']}"
|
||
)
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("✅ Transaction Sage démarrée")
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
# Création document LIVRAISON (type 30)
|
||
process = self.cial.CreateProcess_Document(
|
||
settings.SAGE_TYPE_BON_LIVRAISON
|
||
)
|
||
doc = process.Document
|
||
|
||
try:
|
||
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
|
||
except:
|
||
pass
|
||
|
||
logger.info("📄 Document livraison créé")
|
||
|
||
# Date
|
||
import pywintypes
|
||
|
||
if isinstance(livraison_data["date_livraison"], str):
|
||
date_obj = datetime.fromisoformat(
|
||
livraison_data["date_livraison"]
|
||
)
|
||
elif isinstance(livraison_data["date_livraison"], date):
|
||
date_obj = datetime.combine(
|
||
livraison_data["date_livraison"], datetime.min.time()
|
||
)
|
||
else:
|
||
date_obj = datetime.now()
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
logger.info(f"📅 Date définie: {date_obj.date()}")
|
||
|
||
# Client (CRITIQUE)
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist_client = factory_client.ReadNumero(
|
||
livraison_data["client"]["code"]
|
||
)
|
||
|
||
if not persist_client:
|
||
raise ValueError(
|
||
f"Client {livraison_data['client']['code']} introuvable"
|
||
)
|
||
|
||
client_obj = self._cast_client(persist_client)
|
||
if not client_obj:
|
||
raise ValueError(f"Impossible de charger le client")
|
||
|
||
doc.SetDefaultClient(client_obj)
|
||
doc.Write()
|
||
logger.info(f"👤 Client {livraison_data['client']['code']} associé")
|
||
|
||
# Référence externe (optionnelle)
|
||
if livraison_data.get("reference"):
|
||
try:
|
||
doc.DO_Ref = livraison_data["reference"]
|
||
logger.info(f"📖 Référence: {livraison_data['reference']}")
|
||
except:
|
||
pass
|
||
|
||
# Lignes
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
logger.info(
|
||
f"📦 Ajout de {len(livraison_data['lignes'])} lignes..."
|
||
)
|
||
|
||
for idx, ligne_data in enumerate(livraison_data["lignes"], 1):
|
||
logger.info(
|
||
f"--- Ligne {idx}: {ligne_data['article_code']} ---"
|
||
)
|
||
|
||
# Charger l'article RÉEL depuis Sage
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"❌ Article {ligne_data['article_code']} introuvable dans Sage"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
# Récupérer le prix de vente RÉEL
|
||
prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0))
|
||
designation_sage = getattr(article_obj, "AR_Design", "")
|
||
logger.info(f"💰 Prix Sage: {prix_sage}€")
|
||
|
||
if prix_sage == 0:
|
||
logger.warning(
|
||
f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)"
|
||
)
|
||
|
||
# Créer la ligne
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
logger.info(
|
||
f"✅ Article associé via SetDefaultArticleReference"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet"
|
||
)
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
logger.info(f"✅ Article associé via SetDefaultArticle")
|
||
except Exception as e2:
|
||
logger.error(f"❌ Toutes les méthodes ont échoué")
|
||
ligne_obj.DL_Design = (
|
||
designation_sage
|
||
or ligne_data.get("designation", "")
|
||
)
|
||
ligne_obj.DL_Qte = quantite
|
||
logger.warning("⚠️ Configuration manuelle appliquée")
|
||
|
||
# Vérifier le prix automatique
|
||
prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
logger.info(f"💰 Prix auto chargé: {prix_auto}€")
|
||
|
||
# Ajuster le prix si nécessaire
|
||
prix_a_utiliser = ligne_data.get("prix_unitaire_ht")
|
||
|
||
if prix_a_utiliser is not None and prix_a_utiliser > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser)
|
||
logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€")
|
||
elif prix_auto == 0 and prix_sage > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix_sage)
|
||
logger.info(f"💰 Prix Sage forcé: {prix_sage}€")
|
||
elif prix_auto > 0:
|
||
logger.info(f"💰 Prix auto conservé: {prix_auto}€")
|
||
|
||
prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
montant_ligne = quantite * prix_final
|
||
logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€")
|
||
|
||
# Remise
|
||
remise = ligne_data.get("remise_pourcentage", 0)
|
||
if remise > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(remise)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
montant_apres_remise = montant_ligne * (
|
||
1 - remise / 100
|
||
)
|
||
logger.info(
|
||
f"🎁 Remise {remise}% → {montant_apres_remise}€"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Remise non appliquée: {e}")
|
||
|
||
# Écrire la ligne
|
||
ligne_obj.Write()
|
||
logger.info(f"✅ Ligne {idx} écrite")
|
||
|
||
# Validation
|
||
doc.Write()
|
||
process.Process()
|
||
|
||
if transaction_active:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
|
||
# Récupération numéro
|
||
time.sleep(2)
|
||
|
||
numero_livraison = None
|
||
try:
|
||
doc_result = process.DocumentResult
|
||
if doc_result:
|
||
doc_result = win32com.client.CastTo(
|
||
doc_result, "IBODocumentVente3"
|
||
)
|
||
doc_result.Read()
|
||
numero_livraison = getattr(doc_result, "DO_Piece", "")
|
||
except:
|
||
pass
|
||
|
||
if not numero_livraison:
|
||
numero_livraison = getattr(doc, "DO_Piece", "")
|
||
|
||
# Relecture
|
||
factory_doc = self.cial.FactoryDocumentVente
|
||
persist_reread = factory_doc.ReadPiece(
|
||
settings.SAGE_TYPE_BON_LIVRAISON, numero_livraison
|
||
)
|
||
|
||
if persist_reread:
|
||
doc_final = win32com.client.CastTo(
|
||
persist_reread, "IBODocumentVente3"
|
||
)
|
||
doc_final.Read()
|
||
|
||
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
|
||
else:
|
||
total_ht = 0.0
|
||
total_ttc = 0.0
|
||
|
||
logger.info(
|
||
f"✅✅✅ LIVRAISON CRÉÉE: {numero_livraison} - {total_ttc}€ TTC ✅✅✅"
|
||
)
|
||
|
||
return {
|
||
"numero_livraison": numero_livraison,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"nb_lignes": len(livraison_data["lignes"]),
|
||
"client_code": livraison_data["client"]["code"],
|
||
"date_livraison": str(date_obj.date()),
|
||
}
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur création livraison: {e}", exc_info=True)
|
||
raise RuntimeError(f"Échec création livraison: {str(e)}")
|
||
|
||
def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"🔬 === MODIFICATION LIVRAISON {numero} ===")
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : CHARGER LE DOCUMENT
|
||
# ========================================
|
||
logger.info("📂 Chargement document...")
|
||
|
||
factory = self.cial.FactoryDocumentVente
|
||
persist = None
|
||
|
||
# Chercher le document
|
||
for type_test in [30, settings.SAGE_TYPE_BON_LIVRAISON]:
|
||
try:
|
||
persist_test = factory.ReadPiece(type_test, numero)
|
||
if persist_test:
|
||
persist = persist_test
|
||
logger.info(f" ✅ Document trouvé (type={type_test})")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not persist:
|
||
raise ValueError(f"❌ Livraison {numero} INTROUVABLE")
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
statut_actuel = getattr(doc, "DO_Statut", 0)
|
||
|
||
logger.info(f" 📊 Statut={statut_actuel}")
|
||
|
||
# Vérifier qu'elle n'est pas transformée
|
||
if statut_actuel == 5:
|
||
raise ValueError(f"La livraison {numero} a déjà été transformée")
|
||
|
||
if statut_actuel == 6:
|
||
raise ValueError(f"La livraison {numero} est annulée")
|
||
|
||
# Compter les lignes initiales
|
||
nb_lignes_initial = 0
|
||
try:
|
||
factory_lignes = getattr(
|
||
doc, "FactoryDocumentLigne", None
|
||
) or getattr(doc, "FactoryDocumentVenteLigne", None)
|
||
index = 1
|
||
while index <= 100:
|
||
try:
|
||
ligne_p = factory_lignes.List(index)
|
||
if ligne_p is None:
|
||
break
|
||
nb_lignes_initial += 1
|
||
index += 1
|
||
except:
|
||
break
|
||
|
||
logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Erreur comptage lignes: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS
|
||
# ========================================
|
||
champs_modifies = []
|
||
|
||
modif_date = "date_livraison" in livraison_data
|
||
modif_statut = "statut" in livraison_data
|
||
modif_ref = "reference" in livraison_data
|
||
modif_lignes = (
|
||
"lignes" in livraison_data and livraison_data["lignes"] is not None
|
||
)
|
||
|
||
logger.info(f"📋 Modifications demandées:")
|
||
logger.info(f" Date: {modif_date}")
|
||
logger.info(f" Statut: {modif_statut}")
|
||
logger.info(f" Référence: {modif_ref}")
|
||
logger.info(f" Lignes: {modif_lignes}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : MODIFICATIONS SIMPLES
|
||
# ========================================
|
||
if not modif_lignes and (modif_date or modif_statut or modif_ref):
|
||
logger.info("🎯 Modifications simples (sans lignes)...")
|
||
|
||
if modif_date:
|
||
import pywintypes
|
||
|
||
date_str = livraison_data["date_livraison"]
|
||
|
||
if isinstance(date_str, str):
|
||
date_obj = datetime.fromisoformat(date_str)
|
||
elif isinstance(date_str, date):
|
||
date_obj = datetime.combine(date_str, datetime.min.time())
|
||
else:
|
||
date_obj = date_str
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
champs_modifies.append("date")
|
||
logger.info(f" ✅ Date définie: {date_obj.date()}")
|
||
|
||
if modif_statut:
|
||
nouveau_statut = livraison_data["statut"]
|
||
doc.DO_Statut = nouveau_statut
|
||
champs_modifies.append("statut")
|
||
logger.info(f" ✅ Statut défini: {nouveau_statut}")
|
||
|
||
if modif_ref:
|
||
try:
|
||
doc.DO_Ref = livraison_data["reference"]
|
||
champs_modifies.append("reference")
|
||
logger.info(f" ✅ Référence définie")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Référence non définie: {e}")
|
||
|
||
doc.Write()
|
||
logger.info(" ✅ Write() réussi")
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES
|
||
# ========================================
|
||
elif modif_lignes:
|
||
logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...")
|
||
|
||
nouvelles_lignes = livraison_data["lignes"]
|
||
nb_nouvelles = len(nouvelles_lignes)
|
||
|
||
logger.info(
|
||
f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles"
|
||
)
|
||
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
# SUPPRESSION TOUTES LES LIGNES
|
||
if nb_lignes_initial > 0:
|
||
logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...")
|
||
|
||
for idx in range(nb_lignes_initial, 0, -1):
|
||
try:
|
||
ligne_p = factory_lignes.List(idx)
|
||
if ligne_p:
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
ligne.Read()
|
||
ligne.Remove()
|
||
logger.debug(f" ✅ Ligne {idx} supprimée")
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" ⚠️ Erreur suppression ligne {idx}: {e}"
|
||
)
|
||
|
||
logger.info(" ✅ Toutes les lignes supprimées")
|
||
|
||
# AJOUT NOUVELLES LIGNES
|
||
logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...")
|
||
|
||
for idx, ligne_data in enumerate(nouvelles_lignes, 1):
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"Article {ligne_data['article_code']} introuvable"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
except:
|
||
ligne_obj.DL_Design = ligne_data.get("designation", "")
|
||
ligne_obj.DL_Qte = quantite
|
||
|
||
if ligne_data.get("prix_unitaire_ht"):
|
||
ligne_obj.DL_PrixUnitaire = float(
|
||
ligne_data["prix_unitaire_ht"]
|
||
)
|
||
|
||
if ligne_data.get("remise_pourcentage", 0) > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(
|
||
ligne_data["remise_pourcentage"]
|
||
)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
except:
|
||
pass
|
||
|
||
ligne_obj.Write()
|
||
logger.debug(f" ✅ Ligne {idx} ajoutée")
|
||
|
||
logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées")
|
||
|
||
doc.Write()
|
||
champs_modifies.append("lignes")
|
||
|
||
# ========================================
|
||
# ÉTAPE 5 : RELECTURE ET RETOUR
|
||
# ========================================
|
||
import time
|
||
|
||
time.sleep(1)
|
||
|
||
doc.Read()
|
||
|
||
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
|
||
|
||
logger.info(f"✅✅✅ LIVRAISON MODIFIÉE: {numero} ✅✅✅")
|
||
logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC")
|
||
|
||
return {
|
||
"numero": numero,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"champs_modifies": champs_modifies,
|
||
"statut": getattr(doc, "DO_Statut", 0),
|
||
}
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur technique: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur Sage: {str(e)}")
|
||
|
||
def creer_avoir_enrichi(self, avoir_data: dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
logger.info(
|
||
f"🚀 Début création avoir pour client {avoir_data['client']['code']}"
|
||
)
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("✅ Transaction Sage démarrée")
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
# Création document AVOIR (type 50)
|
||
process = self.cial.CreateProcess_Document(
|
||
settings.SAGE_TYPE_BON_AVOIR
|
||
)
|
||
doc = process.Document
|
||
|
||
try:
|
||
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
|
||
except:
|
||
pass
|
||
|
||
logger.info("📄 Document avoir créé")
|
||
|
||
# Date
|
||
import pywintypes
|
||
|
||
if isinstance(avoir_data["date_avoir"], str):
|
||
date_obj = datetime.fromisoformat(avoir_data["date_avoir"])
|
||
elif isinstance(avoir_data["date_avoir"], date):
|
||
date_obj = datetime.combine(
|
||
avoir_data["date_avoir"], datetime.min.time()
|
||
)
|
||
else:
|
||
date_obj = datetime.now()
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
logger.info(f"📅 Date définie: {date_obj.date()}")
|
||
|
||
# Client (CRITIQUE)
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist_client = factory_client.ReadNumero(
|
||
avoir_data["client"]["code"]
|
||
)
|
||
|
||
if not persist_client:
|
||
raise ValueError(
|
||
f"Client {avoir_data['client']['code']} introuvable"
|
||
)
|
||
|
||
client_obj = self._cast_client(persist_client)
|
||
if not client_obj:
|
||
raise ValueError(f"Impossible de charger le client")
|
||
|
||
doc.SetDefaultClient(client_obj)
|
||
doc.Write()
|
||
logger.info(f"👤 Client {avoir_data['client']['code']} associé")
|
||
|
||
# Référence externe (optionnelle)
|
||
if avoir_data.get("reference"):
|
||
try:
|
||
doc.DO_Ref = avoir_data["reference"]
|
||
logger.info(f"📖 Référence: {avoir_data['reference']}")
|
||
except:
|
||
pass
|
||
|
||
# Lignes
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
logger.info(f"📦 Ajout de {len(avoir_data['lignes'])} lignes...")
|
||
|
||
for idx, ligne_data in enumerate(avoir_data["lignes"], 1):
|
||
logger.info(
|
||
f"--- Ligne {idx}: {ligne_data['article_code']} ---"
|
||
)
|
||
|
||
# Charger l'article RÉEL depuis Sage
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"❌ Article {ligne_data['article_code']} introuvable dans Sage"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
# Récupérer le prix de vente RÉEL
|
||
prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0))
|
||
designation_sage = getattr(article_obj, "AR_Design", "")
|
||
logger.info(f"💰 Prix Sage: {prix_sage}€")
|
||
|
||
if prix_sage == 0:
|
||
logger.warning(
|
||
f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)"
|
||
)
|
||
|
||
# Créer la ligne
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
logger.info(
|
||
f"✅ Article associé via SetDefaultArticleReference"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet"
|
||
)
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
logger.info(f"✅ Article associé via SetDefaultArticle")
|
||
except Exception as e2:
|
||
logger.error(f"❌ Toutes les méthodes ont échoué")
|
||
ligne_obj.DL_Design = (
|
||
designation_sage
|
||
or ligne_data.get("designation", "")
|
||
)
|
||
ligne_obj.DL_Qte = quantite
|
||
logger.warning("⚠️ Configuration manuelle appliquée")
|
||
|
||
# Vérifier le prix automatique
|
||
prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
logger.info(f"💰 Prix auto chargé: {prix_auto}€")
|
||
|
||
# Ajuster le prix si nécessaire
|
||
prix_a_utiliser = ligne_data.get("prix_unitaire_ht")
|
||
|
||
if prix_a_utiliser is not None and prix_a_utiliser > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser)
|
||
logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€")
|
||
elif prix_auto == 0 and prix_sage > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix_sage)
|
||
logger.info(f"💰 Prix Sage forcé: {prix_sage}€")
|
||
elif prix_auto > 0:
|
||
logger.info(f"💰 Prix auto conservé: {prix_auto}€")
|
||
|
||
prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
montant_ligne = quantite * prix_final
|
||
logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€")
|
||
|
||
# Remise
|
||
remise = ligne_data.get("remise_pourcentage", 0)
|
||
if remise > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(remise)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
montant_apres_remise = montant_ligne * (
|
||
1 - remise / 100
|
||
)
|
||
logger.info(
|
||
f"🎁 Remise {remise}% → {montant_apres_remise}€"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Remise non appliquée: {e}")
|
||
|
||
# Écrire la ligne
|
||
ligne_obj.Write()
|
||
logger.info(f"✅ Ligne {idx} écrite")
|
||
|
||
# Validation
|
||
doc.Write()
|
||
process.Process()
|
||
|
||
if transaction_active:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
|
||
# Récupération numéro
|
||
time.sleep(2)
|
||
|
||
numero_avoir = None
|
||
try:
|
||
doc_result = process.DocumentResult
|
||
if doc_result:
|
||
doc_result = win32com.client.CastTo(
|
||
doc_result, "IBODocumentVente3"
|
||
)
|
||
doc_result.Read()
|
||
numero_avoir = getattr(doc_result, "DO_Piece", "")
|
||
except:
|
||
pass
|
||
|
||
if not numero_avoir:
|
||
numero_avoir = getattr(doc, "DO_Piece", "")
|
||
|
||
# Relecture
|
||
factory_doc = self.cial.FactoryDocumentVente
|
||
persist_reread = factory_doc.ReadPiece(
|
||
settings.SAGE_TYPE_BON_AVOIR, numero_avoir
|
||
)
|
||
|
||
if persist_reread:
|
||
doc_final = win32com.client.CastTo(
|
||
persist_reread, "IBODocumentVente3"
|
||
)
|
||
doc_final.Read()
|
||
|
||
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
|
||
else:
|
||
total_ht = 0.0
|
||
total_ttc = 0.0
|
||
|
||
logger.info(
|
||
f"✅✅✅ AVOIR CRÉÉ: {numero_avoir} - {total_ttc}€ TTC ✅✅✅"
|
||
)
|
||
|
||
return {
|
||
"numero_avoir": numero_avoir,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"nb_lignes": len(avoir_data["lignes"]),
|
||
"client_code": avoir_data["client"]["code"],
|
||
"date_avoir": str(date_obj.date()),
|
||
}
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur création avoir: {e}", exc_info=True)
|
||
raise RuntimeError(f"Échec création avoir: {str(e)}")
|
||
|
||
def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"🔬 === MODIFICATION AVOIR {numero} ===")
|
||
|
||
# ÉTAPE 1 : CHARGER LE DOCUMENT
|
||
logger.info("📂 Chargement document...")
|
||
|
||
factory = self.cial.FactoryDocumentVente
|
||
persist = None
|
||
|
||
# Chercher le document
|
||
for type_test in [50, settings.SAGE_TYPE_BON_AVOIR]:
|
||
try:
|
||
persist_test = factory.ReadPiece(type_test, numero)
|
||
if persist_test:
|
||
persist = persist_test
|
||
logger.info(f" ✅ Document trouvé (type={type_test})")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not persist:
|
||
raise ValueError(f"❌ Avoir {numero} INTROUVABLE")
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
statut_actuel = getattr(doc, "DO_Statut", 0)
|
||
|
||
logger.info(f" 📊 Statut={statut_actuel}")
|
||
|
||
# Vérifier qu'il n'est pas transformé
|
||
if statut_actuel == 5:
|
||
raise ValueError(f"L'avoir {numero} a déjà été transformé")
|
||
|
||
if statut_actuel == 6:
|
||
raise ValueError(f"L'avoir {numero} est annulé")
|
||
|
||
# Compter les lignes initiales
|
||
nb_lignes_initial = 0
|
||
try:
|
||
factory_lignes = getattr(
|
||
doc, "FactoryDocumentLigne", None
|
||
) or getattr(doc, "FactoryDocumentVenteLigne", None)
|
||
index = 1
|
||
while index <= 100:
|
||
try:
|
||
ligne_p = factory_lignes.List(index)
|
||
if ligne_p is None:
|
||
break
|
||
nb_lignes_initial += 1
|
||
index += 1
|
||
except:
|
||
break
|
||
|
||
logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Erreur comptage lignes: {e}")
|
||
|
||
# ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS
|
||
champs_modifies = []
|
||
|
||
modif_date = "date_avoir" in avoir_data
|
||
modif_statut = "statut" in avoir_data
|
||
modif_ref = "reference" in avoir_data
|
||
modif_lignes = (
|
||
"lignes" in avoir_data and avoir_data["lignes"] is not None
|
||
)
|
||
|
||
logger.info(f"📋 Modifications demandées:")
|
||
logger.info(f" Date: {modif_date}")
|
||
logger.info(f" Statut: {modif_statut}")
|
||
logger.info(f" Référence: {modif_ref}")
|
||
logger.info(f" Lignes: {modif_lignes}")
|
||
|
||
# ÉTAPE 3 : MODIFICATIONS SIMPLES
|
||
if not modif_lignes and (modif_date or modif_statut or modif_ref):
|
||
logger.info("🎯 Modifications simples (sans lignes)...")
|
||
|
||
if modif_date:
|
||
import pywintypes
|
||
|
||
date_str = avoir_data["date_avoir"]
|
||
|
||
if isinstance(date_str, str):
|
||
date_obj = datetime.fromisoformat(date_str)
|
||
elif isinstance(date_str, date):
|
||
date_obj = datetime.combine(date_str, datetime.min.time())
|
||
else:
|
||
date_obj = date_str
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
champs_modifies.append("date")
|
||
logger.info(f" ✅ Date définie: {date_obj.date()}")
|
||
|
||
if modif_statut:
|
||
nouveau_statut = avoir_data["statut"]
|
||
doc.DO_Statut = nouveau_statut
|
||
champs_modifies.append("statut")
|
||
logger.info(f" ✅ Statut défini: {nouveau_statut}")
|
||
|
||
if modif_ref:
|
||
try:
|
||
doc.DO_Ref = avoir_data["reference"]
|
||
champs_modifies.append("reference")
|
||
logger.info(f" ✅ Référence définie")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Référence non définie: {e}")
|
||
|
||
doc.Write()
|
||
logger.info(" ✅ Write() réussi")
|
||
|
||
# ÉTAPE 4 : REMPLACEMENT COMPLET LIGNES
|
||
elif modif_lignes:
|
||
logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...")
|
||
|
||
nouvelles_lignes = avoir_data["lignes"]
|
||
nb_nouvelles = len(nouvelles_lignes)
|
||
|
||
logger.info(
|
||
f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles"
|
||
)
|
||
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
# SUPPRESSION TOUTES LES LIGNES
|
||
if nb_lignes_initial > 0:
|
||
logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...")
|
||
|
||
for idx in range(nb_lignes_initial, 0, -1):
|
||
try:
|
||
ligne_p = factory_lignes.List(idx)
|
||
if ligne_p:
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
ligne.Read()
|
||
ligne.Remove()
|
||
logger.debug(f" ✅ Ligne {idx} supprimée")
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" ⚠️ Erreur suppression ligne {idx}: {e}"
|
||
)
|
||
|
||
logger.info(" ✅ Toutes les lignes supprimées")
|
||
|
||
# AJOUT NOUVELLES LIGNES
|
||
logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...")
|
||
|
||
for idx, ligne_data in enumerate(nouvelles_lignes, 1):
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"Article {ligne_data['article_code']} introuvable"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
except:
|
||
ligne_obj.DL_Design = ligne_data.get("designation", "")
|
||
ligne_obj.DL_Qte = quantite
|
||
|
||
if ligne_data.get("prix_unitaire_ht"):
|
||
ligne_obj.DL_PrixUnitaire = float(
|
||
ligne_data["prix_unitaire_ht"]
|
||
)
|
||
|
||
if ligne_data.get("remise_pourcentage", 0) > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(
|
||
ligne_data["remise_pourcentage"]
|
||
)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
except:
|
||
pass
|
||
|
||
ligne_obj.Write()
|
||
logger.debug(f" ✅ Ligne {idx} ajoutée")
|
||
|
||
logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées")
|
||
|
||
doc.Write()
|
||
champs_modifies.append("lignes")
|
||
|
||
# ÉTAPE 5 : RELECTURE ET RETOUR
|
||
import time
|
||
|
||
time.sleep(1)
|
||
|
||
doc.Read()
|
||
|
||
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
|
||
|
||
logger.info(f"✅✅✅ AVOIR MODIFIÉ: {numero} ✅✅✅")
|
||
logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC")
|
||
|
||
return {
|
||
"numero": numero,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"champs_modifies": champs_modifies,
|
||
"statut": getattr(doc, "DO_Statut", 0),
|
||
}
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur technique: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur Sage: {str(e)}")
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur technique: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur Sage: {str(e)}")
|
||
|
||
def creer_facture_enrichi(self, facture_data: dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
logger.info(
|
||
f"🚀 Début création facture pour client {facture_data['client']['code']}"
|
||
)
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("✅ Transaction Sage démarrée")
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
# Création document FACTURE (type 60)
|
||
process = self.cial.CreateProcess_Document(
|
||
settings.SAGE_TYPE_FACTURE
|
||
)
|
||
doc = process.Document
|
||
|
||
try:
|
||
doc = win32com.client.CastTo(doc, "IBODocumentVente3")
|
||
except:
|
||
pass
|
||
|
||
logger.info("📄 Document facture créé")
|
||
|
||
# Date
|
||
import pywintypes
|
||
|
||
if isinstance(facture_data["date_facture"], str):
|
||
date_obj = datetime.fromisoformat(facture_data["date_facture"])
|
||
elif isinstance(facture_data["date_facture"], date):
|
||
date_obj = datetime.combine(
|
||
facture_data["date_facture"], datetime.min.time()
|
||
)
|
||
else:
|
||
date_obj = datetime.now()
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
logger.info(f"📅 Date définie: {date_obj.date()}")
|
||
|
||
# Client (CRITIQUE)
|
||
factory_client = self.cial.CptaApplication.FactoryClient
|
||
persist_client = factory_client.ReadNumero(
|
||
facture_data["client"]["code"]
|
||
)
|
||
|
||
if not persist_client:
|
||
raise ValueError(
|
||
f"Client {facture_data['client']['code']} introuvable"
|
||
)
|
||
|
||
client_obj = self._cast_client(persist_client)
|
||
if not client_obj:
|
||
raise ValueError(f"Impossible de charger le client")
|
||
|
||
doc.SetDefaultClient(client_obj)
|
||
doc.Write()
|
||
logger.info(f"👤 Client {facture_data['client']['code']} associé")
|
||
|
||
# Référence externe (optionnelle)
|
||
if facture_data.get("reference"):
|
||
try:
|
||
doc.DO_Ref = facture_data["reference"]
|
||
logger.info(f"📖 Référence: {facture_data['reference']}")
|
||
except:
|
||
pass
|
||
|
||
# ============================================
|
||
# CHAMPS SPÉCIFIQUES FACTURES
|
||
# ============================================
|
||
logger.info("⚙️ Configuration champs spécifiques factures...")
|
||
|
||
# Code journal (si disponible)
|
||
try:
|
||
if hasattr(doc, "DO_CodeJournal"):
|
||
# Essayer de récupérer le code journal par défaut
|
||
try:
|
||
param_societe = (
|
||
self.cial.CptaApplication.ParametreSociete
|
||
)
|
||
journal_defaut = getattr(
|
||
param_societe, "P_CodeJournalVte", "VTE"
|
||
)
|
||
doc.DO_CodeJournal = journal_defaut
|
||
logger.info(f" ✅ Code journal: {journal_defaut}")
|
||
except:
|
||
doc.DO_CodeJournal = "VTE"
|
||
logger.info(" ✅ Code journal: VTE (défaut)")
|
||
except Exception as e:
|
||
logger.debug(f" ⚠️ Code journal: {e}")
|
||
|
||
# Souche (si disponible)
|
||
try:
|
||
if hasattr(doc, "DO_Souche"):
|
||
doc.DO_Souche = 0
|
||
logger.debug(" ✅ Souche: 0 (défaut)")
|
||
except:
|
||
pass
|
||
|
||
# Régime (si disponible)
|
||
try:
|
||
if hasattr(doc, "DO_Regime"):
|
||
doc.DO_Regime = 0
|
||
logger.debug(" ✅ Régime: 0 (défaut)")
|
||
except:
|
||
pass
|
||
|
||
# Lignes
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
logger.info(f"📦 Ajout de {len(facture_data['lignes'])} lignes...")
|
||
|
||
for idx, ligne_data in enumerate(facture_data["lignes"], 1):
|
||
logger.info(
|
||
f"--- Ligne {idx}: {ligne_data['article_code']} ---"
|
||
)
|
||
|
||
# Charger l'article RÉEL depuis Sage
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"❌ Article {ligne_data['article_code']} introuvable dans Sage"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
# Récupérer le prix de vente RÉEL
|
||
prix_sage = float(getattr(article_obj, "AR_PrixVen", 0.0))
|
||
designation_sage = getattr(article_obj, "AR_Design", "")
|
||
logger.info(f"💰 Prix Sage: {prix_sage}€")
|
||
|
||
if prix_sage == 0:
|
||
logger.warning(
|
||
f"⚠️ Article {ligne_data['article_code']} a un prix = 0€ (toléré)"
|
||
)
|
||
|
||
# Créer la ligne
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
logger.info(
|
||
f"✅ Article associé via SetDefaultArticleReference"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"⚠️ SetDefaultArticleReference échoué: {e}, tentative avec objet"
|
||
)
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
logger.info(f"✅ Article associé via SetDefaultArticle")
|
||
except Exception as e2:
|
||
logger.error(f"❌ Toutes les méthodes ont échoué")
|
||
ligne_obj.DL_Design = (
|
||
designation_sage
|
||
or ligne_data.get("designation", "")
|
||
)
|
||
ligne_obj.DL_Qte = quantite
|
||
logger.warning("⚠️ Configuration manuelle appliquée")
|
||
|
||
# Vérifier le prix automatique
|
||
prix_auto = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
logger.info(f"💰 Prix auto chargé: {prix_auto}€")
|
||
|
||
# Ajuster le prix si nécessaire
|
||
prix_a_utiliser = ligne_data.get("prix_unitaire_ht")
|
||
|
||
if prix_a_utiliser is not None and prix_a_utiliser > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix_a_utiliser)
|
||
logger.info(f"💰 Prix personnalisé: {prix_a_utiliser}€")
|
||
elif prix_auto == 0 and prix_sage > 0:
|
||
ligne_obj.DL_PrixUnitaire = float(prix_sage)
|
||
logger.info(f"💰 Prix Sage forcé: {prix_sage}€")
|
||
elif prix_auto > 0:
|
||
logger.info(f"💰 Prix auto conservé: {prix_auto}€")
|
||
|
||
prix_final = float(getattr(ligne_obj, "DL_PrixUnitaire", 0.0))
|
||
montant_ligne = quantite * prix_final
|
||
logger.info(f"✅ {quantite} x {prix_final}€ = {montant_ligne}€")
|
||
|
||
# Remise
|
||
remise = ligne_data.get("remise_pourcentage", 0)
|
||
if remise > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(remise)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
montant_apres_remise = montant_ligne * (
|
||
1 - remise / 100
|
||
)
|
||
logger.info(
|
||
f"🎁 Remise {remise}% → {montant_apres_remise}€"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ Remise non appliquée: {e}")
|
||
|
||
# Écrire la ligne
|
||
ligne_obj.Write()
|
||
logger.info(f"✅ Ligne {idx} écrite")
|
||
|
||
# ============================================
|
||
# VALIDATION FINALE
|
||
# ============================================
|
||
logger.info("💾 Validation facture...")
|
||
|
||
# Réassocier le client avant validation (critique pour factures)
|
||
try:
|
||
doc.SetClient(client_obj)
|
||
logger.debug(" ✅ Client réassocié avant validation")
|
||
except:
|
||
try:
|
||
doc.SetDefaultClient(client_obj)
|
||
except:
|
||
pass
|
||
|
||
doc.Write()
|
||
|
||
logger.info("🔄 Process()...")
|
||
process.Process()
|
||
|
||
if transaction_active:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
logger.info("✅ Transaction committée")
|
||
|
||
# Récupération numéro
|
||
time.sleep(2)
|
||
|
||
numero_facture = None
|
||
try:
|
||
doc_result = process.DocumentResult
|
||
if doc_result:
|
||
doc_result = win32com.client.CastTo(
|
||
doc_result, "IBODocumentVente3"
|
||
)
|
||
doc_result.Read()
|
||
numero_facture = getattr(doc_result, "DO_Piece", "")
|
||
except:
|
||
pass
|
||
|
||
if not numero_facture:
|
||
numero_facture = getattr(doc, "DO_Piece", "")
|
||
|
||
if not numero_facture:
|
||
raise RuntimeError("Numéro facture vide après création")
|
||
|
||
logger.info(f"📄 Numéro facture: {numero_facture}")
|
||
|
||
# Relecture
|
||
factory_doc = self.cial.FactoryDocumentVente
|
||
persist_reread = factory_doc.ReadPiece(
|
||
settings.SAGE_TYPE_FACTURE, numero_facture
|
||
)
|
||
|
||
if persist_reread:
|
||
doc_final = win32com.client.CastTo(
|
||
persist_reread, "IBODocumentVente3"
|
||
)
|
||
doc_final.Read()
|
||
|
||
total_ht = float(getattr(doc_final, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc_final, "DO_TotalTTC", 0.0))
|
||
else:
|
||
total_ht = 0.0
|
||
total_ttc = 0.0
|
||
|
||
logger.info(
|
||
f"✅✅✅ FACTURE CRÉÉE: {numero_facture} - {total_ttc}€ TTC ✅✅✅"
|
||
)
|
||
|
||
return {
|
||
"numero_facture": numero_facture,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"nb_lignes": len(facture_data["lignes"]),
|
||
"client_code": facture_data["client"]["code"],
|
||
"date_facture": str(date_obj.date()),
|
||
}
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
logger.error("❌ Transaction annulée (rollback)")
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur création facture: {e}", exc_info=True)
|
||
raise RuntimeError(f"Échec création facture: {str(e)}")
|
||
|
||
def modifier_facture(self, numero: str, facture_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"🔬 === MODIFICATION FACTURE {numero} ===")
|
||
|
||
# ÉTAPE 1 : CHARGER LE DOCUMENT
|
||
logger.info("📂 Chargement document...")
|
||
|
||
factory = self.cial.FactoryDocumentVente
|
||
persist = None
|
||
|
||
# Chercher le document
|
||
for type_test in [60, 5, settings.SAGE_TYPE_FACTURE]:
|
||
try:
|
||
persist_test = factory.ReadPiece(type_test, numero)
|
||
if persist_test:
|
||
persist = persist_test
|
||
logger.info(f" ✅ Document trouvé (type={type_test})")
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not persist:
|
||
raise ValueError(f"❌ Facture {numero} INTROUVABLE")
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
statut_actuel = getattr(doc, "DO_Statut", 0)
|
||
|
||
logger.info(f" 📊 Statut={statut_actuel}")
|
||
|
||
# Vérifier qu'elle n'est pas transformée ou annulée
|
||
if statut_actuel == 5:
|
||
raise ValueError(f"La facture {numero} a déjà été transformée")
|
||
|
||
if statut_actuel == 6:
|
||
raise ValueError(f"La facture {numero} est annulée")
|
||
|
||
# Vérifier client initial
|
||
client_code_initial = ""
|
||
try:
|
||
client_obj = getattr(doc, "Client", None)
|
||
if client_obj:
|
||
client_obj.Read()
|
||
client_code_initial = getattr(client_obj, "CT_Num", "").strip()
|
||
logger.info(f" 👤 Client initial: {client_code_initial}")
|
||
except Exception as e:
|
||
logger.error(f" ❌ Erreur lecture client initial: {e}")
|
||
|
||
if not client_code_initial:
|
||
raise ValueError("❌ Client introuvable dans le document")
|
||
|
||
# Compter les lignes initiales
|
||
nb_lignes_initial = 0
|
||
try:
|
||
factory_lignes = getattr(
|
||
doc, "FactoryDocumentLigne", None
|
||
) or getattr(doc, "FactoryDocumentVenteLigne", None)
|
||
index = 1
|
||
while index <= 100:
|
||
try:
|
||
ligne_p = factory_lignes.List(index)
|
||
if ligne_p is None:
|
||
break
|
||
nb_lignes_initial += 1
|
||
index += 1
|
||
except:
|
||
break
|
||
|
||
logger.info(f" 📦 Lignes initiales: {nb_lignes_initial}")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Erreur comptage lignes: {e}")
|
||
|
||
# ÉTAPE 2 : DÉTERMINER LES MODIFICATIONS
|
||
champs_modifies = []
|
||
|
||
modif_date = "date_facture" in facture_data
|
||
modif_statut = "statut" in facture_data
|
||
modif_ref = "reference" in facture_data
|
||
modif_lignes = (
|
||
"lignes" in facture_data and facture_data["lignes"] is not None
|
||
)
|
||
|
||
logger.info(f"📋 Modifications demandées:")
|
||
logger.info(f" Date: {modif_date}")
|
||
logger.info(f" Statut: {modif_statut}")
|
||
logger.info(f" Référence: {modif_ref}")
|
||
logger.info(f" Lignes: {modif_lignes}")
|
||
|
||
# ÉTAPE 3 : TEST WRITE() BASIQUE
|
||
logger.info("🧪 Test Write() basique (sans modification)...")
|
||
|
||
try:
|
||
doc.Write()
|
||
logger.info(" ✅ Write() basique OK")
|
||
doc.Read()
|
||
except Exception as e:
|
||
logger.error(f" ❌ Write() basique ÉCHOUE: {e}")
|
||
logger.error(f" ❌ ABANDON: Le document est VERROUILLÉ")
|
||
raise ValueError(
|
||
f"Document verrouillé, impossible de modifier: {e}"
|
||
)
|
||
|
||
# ÉTAPE 4 : MODIFICATIONS SIMPLES
|
||
if not modif_lignes and (modif_date or modif_statut or modif_ref):
|
||
logger.info("🎯 Modifications simples (sans lignes)...")
|
||
|
||
if modif_date:
|
||
import pywintypes
|
||
|
||
date_str = facture_data["date_facture"]
|
||
|
||
if isinstance(date_str, str):
|
||
date_obj = datetime.fromisoformat(date_str)
|
||
elif isinstance(date_str, date):
|
||
date_obj = datetime.combine(date_str, datetime.min.time())
|
||
else:
|
||
date_obj = date_str
|
||
|
||
doc.DO_Date = pywintypes.Time(date_obj)
|
||
champs_modifies.append("date")
|
||
logger.info(f" ✅ Date définie: {date_obj.date()}")
|
||
|
||
if modif_statut:
|
||
nouveau_statut = facture_data["statut"]
|
||
doc.DO_Statut = nouveau_statut
|
||
champs_modifies.append("statut")
|
||
logger.info(f" ✅ Statut défini: {nouveau_statut}")
|
||
|
||
if modif_ref:
|
||
try:
|
||
doc.DO_Ref = facture_data["reference"]
|
||
champs_modifies.append("reference")
|
||
logger.info(f" ✅ Référence définie")
|
||
except Exception as e:
|
||
logger.warning(f" ⚠️ Référence non définie: {e}")
|
||
|
||
doc.Write()
|
||
logger.info(" ✅ Write() réussi")
|
||
|
||
# ÉTAPE 5 : REMPLACEMENT COMPLET LIGNES
|
||
elif modif_lignes:
|
||
logger.info("🎯 REMPLACEMENT COMPLET DES LIGNES...")
|
||
|
||
nouvelles_lignes = facture_data["lignes"]
|
||
nb_nouvelles = len(nouvelles_lignes)
|
||
|
||
logger.info(
|
||
f" 📊 {nb_lignes_initial} lignes existantes → {nb_nouvelles} nouvelles"
|
||
)
|
||
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentVenteLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
|
||
# SUPPRESSION TOUTES LES LIGNES
|
||
if nb_lignes_initial > 0:
|
||
logger.info(f" 🗑️ Suppression de {nb_lignes_initial} lignes...")
|
||
|
||
for idx in range(nb_lignes_initial, 0, -1):
|
||
try:
|
||
ligne_p = factory_lignes.List(idx)
|
||
if ligne_p:
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
ligne.Read()
|
||
ligne.Remove()
|
||
logger.debug(f" ✅ Ligne {idx} supprimée")
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" ⚠️ Erreur suppression ligne {idx}: {e}"
|
||
)
|
||
|
||
logger.info(" ✅ Toutes les lignes supprimées")
|
||
|
||
# AJOUT NOUVELLES LIGNES
|
||
logger.info(f" ➕ Ajout de {nb_nouvelles} nouvelles lignes...")
|
||
|
||
for idx, ligne_data in enumerate(nouvelles_lignes, 1):
|
||
persist_article = factory_article.ReadReference(
|
||
ligne_data["article_code"]
|
||
)
|
||
if not persist_article:
|
||
raise ValueError(
|
||
f"Article {ligne_data['article_code']} introuvable"
|
||
)
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentVenteLigne3"
|
||
)
|
||
|
||
quantite = float(ligne_data["quantite"])
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
ligne_data["article_code"], quantite
|
||
)
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(article_obj, quantite)
|
||
except:
|
||
ligne_obj.DL_Design = ligne_data.get("designation", "")
|
||
ligne_obj.DL_Qte = quantite
|
||
|
||
if ligne_data.get("prix_unitaire_ht"):
|
||
ligne_obj.DL_PrixUnitaire = float(
|
||
ligne_data["prix_unitaire_ht"]
|
||
)
|
||
|
||
if ligne_data.get("remise_pourcentage", 0) > 0:
|
||
try:
|
||
ligne_obj.DL_Remise01REM_Valeur = float(
|
||
ligne_data["remise_pourcentage"]
|
||
)
|
||
ligne_obj.DL_Remise01REM_Type = 0
|
||
except:
|
||
pass
|
||
|
||
ligne_obj.Write()
|
||
logger.debug(f" ✅ Ligne {idx} ajoutée")
|
||
|
||
logger.info(f" ✅ {nb_nouvelles} nouvelles lignes ajoutées")
|
||
|
||
doc.Write()
|
||
champs_modifies.append("lignes")
|
||
|
||
# ÉTAPE 6 : RELECTURE ET RETOUR
|
||
import time
|
||
|
||
time.sleep(1)
|
||
|
||
doc.Read()
|
||
|
||
total_ht = float(getattr(doc, "DO_TotalHT", 0.0))
|
||
total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0))
|
||
|
||
logger.info(f"✅✅✅ FACTURE MODIFIÉE: {numero} ✅✅✅")
|
||
logger.info(f" 💰 Totaux: {total_ht}€ HT / {total_ttc}€ TTC")
|
||
|
||
return {
|
||
"numero": numero,
|
||
"total_ht": total_ht,
|
||
"total_ttc": total_ttc,
|
||
"champs_modifies": champs_modifies,
|
||
"statut": getattr(doc, "DO_Statut", 0),
|
||
}
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur technique: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur Sage: {str(e)}")
|
||
|
||
def generer_pdf_document(self, numero: str, type_doc: int) -> bytes:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"📄 Génération PDF: {numero} (type={type_doc})")
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : CHARGER LE DOCUMENT
|
||
# ========================================
|
||
factory = self.cial.FactoryDocumentVente
|
||
|
||
# Essayer ReadPiece
|
||
persist = factory.ReadPiece(type_doc, numero)
|
||
|
||
if not persist:
|
||
# Fallback: chercher dans List()
|
||
logger.debug(f"ReadPiece échoué, recherche dans List()...")
|
||
persist = self._find_document_in_list(numero, type_doc)
|
||
|
||
if not persist:
|
||
raise ValueError(f"Document {numero} (type {type_doc}) introuvable")
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
|
||
doc.Read()
|
||
|
||
logger.info(f"✅ Document chargé: {numero}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : IDENTIFIER L'ÉTAT CRYSTAL
|
||
# ========================================
|
||
# Mapping des types vers les noms d'états Sage
|
||
etats_sage = {
|
||
0: "VT_DEVIS.RPT", # Devis
|
||
10: "VT_CMDE.RPT", # Bon de commande
|
||
20: "VT_PREP.RPT", # Préparation
|
||
30: "VT_BL.RPT", # Bon de livraison
|
||
40: "VT_BR.RPT", # Bon de retour
|
||
50: "VT_AVOIR.RPT", # Bon d'avoir
|
||
60: "VT_FACT.RPT", # Facture
|
||
}
|
||
|
||
etat_nom = etats_sage.get(type_doc)
|
||
|
||
if not etat_nom:
|
||
raise ValueError(f"Type de document non supporté: {type_doc}")
|
||
|
||
logger.info(f"📋 État Sage: {etat_nom}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : GÉNÉRER LE PDF VIA L'ÉTAT
|
||
# ========================================
|
||
try:
|
||
# Accéder au gestionnaire d'états
|
||
factory_etat = self.cial.FactoryEtat
|
||
|
||
# Charger l'état
|
||
etat = factory_etat.ReadNom(etat_nom)
|
||
|
||
if not etat:
|
||
raise RuntimeError(f"État {etat_nom} non trouvé dans Sage")
|
||
|
||
# Paramétrer l'état
|
||
etat.Destination = 6 # 6 = PDF
|
||
etat.Preview = False # Pas de prévisualisation
|
||
|
||
# Définir le fichier de sortie temporaire
|
||
import tempfile
|
||
import os
|
||
|
||
temp_dir = tempfile.gettempdir()
|
||
pdf_filename = f"sage_pdf_{numero}_{int(time.time())}.pdf"
|
||
pdf_path = os.path.join(temp_dir, pdf_filename)
|
||
|
||
etat.FileName = pdf_path
|
||
|
||
# Définir le filtre (seulement ce document)
|
||
etat.Selection = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'"
|
||
|
||
logger.info(f"📁 Fichier temporaire: {pdf_path}")
|
||
|
||
# Exécuter l'état
|
||
logger.info("🔄 Exécution état Crystal...")
|
||
etat.Start()
|
||
|
||
# Attendre que le fichier soit créé
|
||
max_wait = 30 # 30 secondes max
|
||
waited = 0
|
||
|
||
while not os.path.exists(pdf_path) and waited < max_wait:
|
||
time.sleep(0.5)
|
||
waited += 0.5
|
||
|
||
if not os.path.exists(pdf_path):
|
||
raise RuntimeError(
|
||
f"Le fichier PDF n'a pas été créé après {max_wait}s"
|
||
)
|
||
|
||
# Lire le fichier PDF
|
||
with open(pdf_path, "rb") as f:
|
||
pdf_bytes = f.read()
|
||
|
||
logger.info(f"✅ PDF lu: {len(pdf_bytes)} octets")
|
||
|
||
# Nettoyer le fichier temporaire
|
||
try:
|
||
os.remove(pdf_path)
|
||
logger.debug(f"🗑️ Fichier temporaire supprimé")
|
||
except:
|
||
pass
|
||
|
||
if len(pdf_bytes) == 0:
|
||
raise RuntimeError("Le PDF généré est vide")
|
||
|
||
logger.info(
|
||
f"✅✅✅ PDF GÉNÉRÉ: {numero} ({len(pdf_bytes)} octets) ✅✅✅"
|
||
)
|
||
|
||
return pdf_bytes
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur génération état: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur génération PDF: {str(e)}")
|
||
|
||
except ValueError as e:
|
||
logger.error(f"❌ Erreur métier: {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Erreur technique: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur Sage: {str(e)}")
|
||
|
||
def creer_article(self, article_data: dict) -> dict:
|
||
with self._com_context(), self._lock_com:
|
||
try:
|
||
logger.info("[ARTICLE] === CREATION ARTICLE ===")
|
||
|
||
# Transaction
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("Transaction Sage démarrée")
|
||
except Exception as e:
|
||
logger.debug(f"BeginTrans non disponible : {e}")
|
||
|
||
try:
|
||
# ========================================
|
||
# ÉTAPE 0 : DÉCOUVRIR DÉPÔTS
|
||
# ========================================
|
||
depots_disponibles = []
|
||
depot_a_utiliser = None
|
||
depot_code_demande = article_data.get("depot_code")
|
||
|
||
try:
|
||
factory_depot = self.cial.FactoryDepot
|
||
index = 1
|
||
|
||
while index <= 100:
|
||
try:
|
||
persist = factory_depot.List(index)
|
||
if persist is None:
|
||
break
|
||
|
||
depot_obj = win32com.client.CastTo(persist, "IBODepot3")
|
||
depot_obj.Read()
|
||
|
||
code = getattr(depot_obj, "DE_Code", "").strip()
|
||
if not code:
|
||
index += 1
|
||
continue
|
||
|
||
numero = int(getattr(depot_obj, "Compteur", 0))
|
||
intitule = getattr(
|
||
depot_obj, "DE_Intitule", f"Depot {code}"
|
||
)
|
||
|
||
depot_info = {
|
||
"code": code,
|
||
"numero": numero,
|
||
"intitule": intitule,
|
||
"objet": depot_obj,
|
||
}
|
||
|
||
depots_disponibles.append(depot_info)
|
||
|
||
if depot_code_demande and code == depot_code_demande:
|
||
depot_a_utiliser = depot_info
|
||
elif not depot_code_demande and not depot_a_utiliser:
|
||
depot_a_utiliser = depot_info
|
||
|
||
index += 1
|
||
|
||
except Exception as e:
|
||
if "Acces refuse" in str(e):
|
||
break
|
||
index += 1
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[DEPOT] Erreur découverte dépôts : {e}")
|
||
|
||
if not depots_disponibles:
|
||
raise ValueError(
|
||
"Aucun dépôt trouvé dans Sage. Créez d'abord un dépôt."
|
||
)
|
||
|
||
if not depot_a_utiliser:
|
||
depot_a_utiliser = depots_disponibles[0]
|
||
|
||
logger.info(
|
||
f"[DEPOT] Utilisation : '{depot_a_utiliser['code']}' ({depot_a_utiliser['intitule']})"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : VALIDATION & NETTOYAGE
|
||
# ========================================
|
||
reference = article_data.get("reference", "").upper().strip()
|
||
if not reference:
|
||
raise ValueError("La référence est obligatoire")
|
||
|
||
if len(reference) > 18:
|
||
raise ValueError(
|
||
"La référence ne peut pas dépasser 18 caractères"
|
||
)
|
||
|
||
designation = article_data.get("designation", "").strip()
|
||
if not designation:
|
||
raise ValueError("La désignation est obligatoire")
|
||
|
||
if len(designation) > 69:
|
||
designation = designation[:69]
|
||
|
||
# Récupération des STOCKS
|
||
stock_reel = article_data.get("stock_reel", 0.0)
|
||
stock_mini = article_data.get("stock_mini", 0.0)
|
||
stock_maxi = article_data.get("stock_maxi", 0.0)
|
||
|
||
logger.info(f"[ARTICLE] Référence : {reference}")
|
||
logger.info(f"[ARTICLE] Désignation : {designation}")
|
||
logger.info(f"[ARTICLE] Stock réel demandé : {stock_reel}")
|
||
logger.info(f"[ARTICLE] Stock mini demandé : {stock_mini}")
|
||
logger.info(f"[ARTICLE] Stock maxi demandé : {stock_maxi}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : VÉRIFIER SI EXISTE DÉJÀ
|
||
# ========================================
|
||
factory = self.cial.FactoryArticle
|
||
try:
|
||
article_existant = factory.ReadReference(reference)
|
||
if article_existant:
|
||
raise ValueError(f"L'article {reference} existe déjà")
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
if (
|
||
"Enregistrement non trouve" in error_msg
|
||
or "non trouve" in error_msg
|
||
or "-2607" in error_msg
|
||
):
|
||
logger.debug(
|
||
f"[ARTICLE] {reference} n'existe pas encore, création possible"
|
||
)
|
||
else:
|
||
logger.error(f"[ARTICLE] Erreur vérification : {e}")
|
||
raise
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : CRÉER L'ARTICLE
|
||
# ========================================
|
||
persist = factory.Create()
|
||
article = win32com.client.CastTo(persist, "IBOArticle3")
|
||
article.SetDefault()
|
||
|
||
# Champs de base
|
||
article.AR_Ref = reference
|
||
article.AR_Design = designation
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : TROUVER ARTICLE MODÈLE VIA SQL
|
||
# ========================================
|
||
logger.info("[MODELE] Recherche article modèle via SQL...")
|
||
|
||
article_modele_ref = None
|
||
article_modele = None
|
||
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT TOP 1 AR_Ref
|
||
FROM F_ARTICLE
|
||
WHERE AR_Sommeil = 0
|
||
ORDER BY AR_Ref
|
||
"""
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
article_modele_ref = self._safe_strip(row.AR_Ref)
|
||
logger.info(
|
||
f" [SQL] Article modèle trouvé : {article_modele_ref}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(f" [SQL] Erreur recherche article : {e}")
|
||
|
||
# Charger l'article modèle via COM
|
||
if article_modele_ref:
|
||
try:
|
||
persist_modele = factory.ReadReference(article_modele_ref)
|
||
|
||
if persist_modele:
|
||
article_modele = win32com.client.CastTo(
|
||
persist_modele, "IBOArticle3"
|
||
)
|
||
article_modele.Read()
|
||
logger.info(
|
||
f" [OK] Article modèle chargé : {article_modele_ref}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(f" [WARN] Erreur chargement modèle : {e}")
|
||
article_modele = None
|
||
|
||
if not article_modele:
|
||
raise ValueError(
|
||
"Aucun article modèle trouvé dans Sage.\n"
|
||
"Créez au moins un article manuellement dans Sage pour servir de modèle."
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 5 : COPIER UNITÉ + FAMILLE
|
||
# ========================================
|
||
logger.info("[OBJETS] Copie Unite + Famille depuis modèle...")
|
||
|
||
# Unite
|
||
unite_trouvee = False
|
||
try:
|
||
unite_obj = getattr(article_modele, "Unite", None)
|
||
if unite_obj:
|
||
article.Unite = unite_obj
|
||
logger.info(
|
||
f" [OK] Objet Unite copié depuis {article_modele_ref}"
|
||
)
|
||
unite_trouvee = True
|
||
except Exception as e:
|
||
logger.debug(f" Unite non copiable : {str(e)[:80]}")
|
||
|
||
if not unite_trouvee:
|
||
raise ValueError(
|
||
"Impossible de copier l'unité de vente depuis le modèle"
|
||
)
|
||
|
||
# Famille
|
||
famille_trouvee = False
|
||
famille_code_personnalise = article_data.get("famille")
|
||
famille_obj = None
|
||
|
||
if famille_code_personnalise:
|
||
logger.info(
|
||
f" [FAMILLE] Code personnalisé demandé : {famille_code_personnalise}"
|
||
)
|
||
|
||
try:
|
||
# Vérifier existence via SQL
|
||
famille_existe_sql = False
|
||
famille_code_exact = None
|
||
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT FA_CodeFamille, FA_Type
|
||
FROM F_FAMILLE
|
||
WHERE UPPER(FA_CodeFamille) = ?
|
||
""",
|
||
(famille_code_personnalise.upper(),),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
famille_code_exact = self._safe_strip(
|
||
row.FA_CodeFamille
|
||
)
|
||
famille_existe_sql = True
|
||
logger.info(
|
||
f" [SQL] Famille trouvée : {famille_code_exact}"
|
||
)
|
||
else:
|
||
raise ValueError(
|
||
f"Famille '{famille_code_personnalise}' introuvable"
|
||
)
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e_sql:
|
||
logger.warning(f" [SQL] Erreur : {e_sql}")
|
||
|
||
# Charger via COM
|
||
if famille_existe_sql and famille_code_exact:
|
||
factory_famille = self.cial.FactoryFamille
|
||
try:
|
||
index = 1
|
||
max_scan = 1000
|
||
|
||
while index <= max_scan:
|
||
try:
|
||
persist_test = factory_famille.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
fam_test = win32com.client.CastTo(
|
||
persist_test, "IBOFamille3"
|
||
)
|
||
fam_test.Read()
|
||
|
||
code_test = (
|
||
getattr(fam_test, "FA_CodeFamille", "")
|
||
.strip()
|
||
.upper()
|
||
)
|
||
|
||
if code_test == famille_code_exact.upper():
|
||
famille_obj = fam_test
|
||
famille_trouvee = True
|
||
logger.info(
|
||
f" [OK] Famille trouvée à l'index {index}"
|
||
)
|
||
break
|
||
|
||
index += 1
|
||
|
||
except Exception as e:
|
||
if "Accès refusé" in str(e):
|
||
break
|
||
index += 1
|
||
|
||
if famille_obj:
|
||
famille_obj.Read()
|
||
article.Famille = famille_obj
|
||
logger.info(
|
||
f" [OK] Famille '{famille_code_personnalise}' assignée"
|
||
)
|
||
else:
|
||
raise ValueError(
|
||
f"Famille '{famille_code_personnalise}' inaccessible via COM"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(f" [COM] Erreur scanner : {e}")
|
||
raise
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" [WARN] Famille personnalisée non chargée : {str(e)[:100]}"
|
||
)
|
||
|
||
# Si pas de famille perso, copier depuis le modèle
|
||
if not famille_trouvee:
|
||
try:
|
||
famille_obj = getattr(article_modele, "Famille", None)
|
||
if famille_obj:
|
||
article.Famille = famille_obj
|
||
logger.info(
|
||
f" [OK] Objet Famille copié depuis {article_modele_ref}"
|
||
)
|
||
famille_trouvee = True
|
||
except Exception as e:
|
||
logger.debug(f" Famille non copiable : {str(e)[:80]}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 6 : CHAMPS OBLIGATOIRES
|
||
# ========================================
|
||
logger.info("[CHAMPS] Copie champs obligatoires depuis modèle...")
|
||
|
||
# Types et natures
|
||
article.AR_Type = int(getattr(article_modele, "AR_Type", 0))
|
||
article.AR_Nature = int(getattr(article_modele, "AR_Nature", 0))
|
||
article.AR_Nomencl = int(getattr(article_modele, "AR_Nomencl", 0))
|
||
|
||
# Suivi stock (forcé à 2 = FIFO/LIFO)
|
||
article.AR_SuiviStock = 2
|
||
logger.info(f" [OK] AR_SuiviStock=2 (FIFO/LIFO)")
|
||
|
||
# ========================================
|
||
# ÉTAPE 7 : PRIX
|
||
# ========================================
|
||
prix_vente = article_data.get("prix_vente")
|
||
if prix_vente is not None:
|
||
try:
|
||
article.AR_PrixVen = float(prix_vente)
|
||
logger.info(f" Prix vente : {prix_vente} EUR")
|
||
except Exception as e:
|
||
logger.warning(f" Prix vente erreur : {str(e)[:100]}")
|
||
|
||
prix_achat = article_data.get("prix_achat")
|
||
if prix_achat is not None:
|
||
try:
|
||
try:
|
||
article.AR_PrixAch = float(prix_achat)
|
||
logger.info(
|
||
f" Prix achat (AR_PrixAch) : {prix_achat} EUR"
|
||
)
|
||
except:
|
||
article.AR_PrixAchat = float(prix_achat)
|
||
logger.info(
|
||
f" Prix achat (AR_PrixAchat) : {prix_achat} EUR"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f" Prix achat erreur : {str(e)[:100]}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 8 : CODE EAN
|
||
# ========================================
|
||
code_ean = article_data.get("code_ean")
|
||
if code_ean:
|
||
article.AR_CodeBarre = str(code_ean)
|
||
logger.info(f" Code EAN/Barre : {code_ean}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 9 : DESCRIPTION
|
||
# ========================================
|
||
description = article_data.get("description")
|
||
if description:
|
||
try:
|
||
article.AR_Commentaire = description
|
||
logger.info(f" Description définie")
|
||
except:
|
||
pass
|
||
|
||
# ========================================
|
||
# ÉTAPE 10 : ÉCRITURE ARTICLE
|
||
# ========================================
|
||
logger.info("[ARTICLE] Écriture dans Sage...")
|
||
|
||
try:
|
||
article.Write()
|
||
logger.info(" [OK] Write() réussi")
|
||
except Exception as e:
|
||
error_detail = str(e)
|
||
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_detail = f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
except:
|
||
pass
|
||
|
||
logger.error(f" [ERREUR] Write() échoué : {error_detail}")
|
||
raise RuntimeError(f"Échec création article : {error_detail}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 11 : DÉFINIR LE STOCK DANS F_ARTSTOCK (CRITIQUE)
|
||
# ========================================
|
||
stock_defini = False
|
||
stock_erreur = None
|
||
|
||
# Vérifier si on a des valeurs de stock à définir
|
||
has_stock_values = stock_reel or stock_mini or stock_maxi
|
||
|
||
if has_stock_values:
|
||
logger.info(
|
||
f"[STOCK] Définition stock dans F_ARTSTOCK (dépôt '{depot_a_utiliser['code']}')..."
|
||
)
|
||
|
||
try:
|
||
depot_obj = depot_a_utiliser["objet"]
|
||
|
||
# Chercher FactoryArticleStock ou FactoryDepotStock
|
||
factory_stock = None
|
||
for factory_name in [
|
||
"FactoryArticleStock",
|
||
"FactoryDepotStock",
|
||
]:
|
||
try:
|
||
factory_stock = getattr(
|
||
depot_obj, factory_name, None
|
||
)
|
||
if factory_stock:
|
||
logger.info(
|
||
f" Factory trouvée : {factory_name}"
|
||
)
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if not factory_stock:
|
||
raise RuntimeError(
|
||
"Factory de stock introuvable sur le dépôt"
|
||
)
|
||
|
||
# Créer l'entrée de stock dans F_ARTSTOCK
|
||
stock_persist = factory_stock.Create()
|
||
stock_obj = win32com.client.CastTo(
|
||
stock_persist, "IBODepotStock3"
|
||
)
|
||
stock_obj.SetDefault()
|
||
|
||
# Référence article
|
||
stock_obj.AR_Ref = reference
|
||
|
||
# Stock réel
|
||
if stock_reel:
|
||
stock_obj.AS_QteSto = float(stock_reel)
|
||
logger.info(f" AS_QteSto = {stock_reel}")
|
||
|
||
# Stock minimum
|
||
if stock_mini:
|
||
try:
|
||
stock_obj.AS_QteMini = float(stock_mini)
|
||
logger.info(f" AS_QteMini = {stock_mini}")
|
||
except Exception as e:
|
||
logger.warning(f" AS_QteMini non défini : {e}")
|
||
|
||
# Stock maximum
|
||
if stock_maxi:
|
||
try:
|
||
stock_obj.AS_QteMaxi = float(stock_maxi)
|
||
logger.info(f" AS_QteMaxi = {stock_maxi}")
|
||
except Exception as e:
|
||
logger.warning(f" AS_QteMaxi non défini : {e}")
|
||
|
||
stock_obj.Write()
|
||
|
||
stock_defini = True
|
||
logger.info(
|
||
f" [OK] Stock défini dans F_ARTSTOCK : réel={stock_reel}, min={stock_mini}, max={stock_maxi}"
|
||
)
|
||
|
||
except Exception as e:
|
||
stock_erreur = str(e)
|
||
logger.error(
|
||
f" [ERREUR] Stock non défini dans F_ARTSTOCK : {e}",
|
||
exc_info=True,
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 12 : COMMIT
|
||
# ========================================
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
logger.info(
|
||
"[COMMIT] Transaction committée - Article persiste dans Sage"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[COMMIT] Erreur commit : {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 13 : VÉRIFICATION & RELECTURE
|
||
# ========================================
|
||
logger.info("[VERIF] Relecture article créé...")
|
||
|
||
article_cree_persist = factory.ReadReference(reference)
|
||
if not article_cree_persist:
|
||
raise RuntimeError(
|
||
"Article créé mais introuvable à la relecture"
|
||
)
|
||
|
||
article_cree = win32com.client.CastTo(
|
||
article_cree_persist, "IBOArticle3"
|
||
)
|
||
article_cree.Read()
|
||
|
||
# ========================================
|
||
# ÉTAPE 14 : VÉRIFIER LE STOCK DANS F_ARTSTOCK VIA SQL
|
||
# ========================================
|
||
stocks_par_depot = []
|
||
stock_total = 0.0
|
||
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# Vérifier si le stock a été créé dans F_ARTSTOCK
|
||
cursor.execute(
|
||
"""
|
||
SELECT
|
||
d.DE_Code,
|
||
s.AS_QteSto,
|
||
s.AS_QteMini,
|
||
s.AS_QteMaxi
|
||
FROM F_ARTSTOCK s
|
||
LEFT JOIN F_DEPOT d ON s.DE_No = d.DE_No
|
||
WHERE s.AR_Ref = ?
|
||
""",
|
||
(reference.upper(),),
|
||
)
|
||
|
||
depot_rows = cursor.fetchall()
|
||
|
||
for depot_row in depot_rows:
|
||
if len(depot_row) >= 4:
|
||
qte = float(depot_row[1]) if depot_row[1] else 0.0
|
||
stock_total += qte
|
||
|
||
stocks_par_depot.append(
|
||
{
|
||
"depot_code": self._safe_strip(
|
||
depot_row[0]
|
||
),
|
||
"quantite": qte,
|
||
"qte_mini": (
|
||
float(depot_row[2])
|
||
if depot_row[2]
|
||
else 0.0
|
||
),
|
||
"qte_maxi": (
|
||
float(depot_row[3])
|
||
if depot_row[3]
|
||
else 0.0
|
||
),
|
||
}
|
||
)
|
||
|
||
logger.info(
|
||
f"[VERIF] Stock dans F_ARTSTOCK : {stock_total} unités dans {len(stocks_par_depot)} dépôt(s)"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[VERIF] Impossible de vérifier le stock dans F_ARTSTOCK : {e}"
|
||
)
|
||
|
||
logger.info(
|
||
f"[OK] ARTICLE CREE : {reference} - Stock total : {stock_total}"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 15 : EXTRACTION COMPLÈTE
|
||
# ========================================
|
||
logger.info("[EXTRACTION] Extraction complète de l'article créé...")
|
||
|
||
# Utiliser _extraire_article() pour avoir TOUS les champs
|
||
resultat = self._extraire_article(article_cree)
|
||
|
||
if not resultat:
|
||
# Fallback si extraction échoue
|
||
resultat = {
|
||
"reference": reference,
|
||
"designation": designation,
|
||
}
|
||
|
||
# ========================================
|
||
# ÉTAPE 16 : FORCER LES VALEURS DE STOCK DEPUIS F_ARTSTOCK
|
||
# ========================================
|
||
# ✅ 1. STOCK (forcer les valeurs depuis F_ARTSTOCK)
|
||
resultat["stock_reel"] = stock_total
|
||
|
||
if stock_mini:
|
||
resultat["stock_mini"] = float(stock_mini)
|
||
|
||
if stock_maxi:
|
||
resultat["stock_maxi"] = float(stock_maxi)
|
||
|
||
# Stock disponible = stock réel (article neuf, pas de réservation)
|
||
resultat["stock_disponible"] = stock_total
|
||
resultat["stock_reserve"] = 0.0
|
||
resultat["stock_commande"] = 0.0
|
||
|
||
# ✅ 2. PRIX
|
||
if prix_vente is not None:
|
||
resultat["prix_vente"] = float(prix_vente)
|
||
|
||
if prix_achat is not None:
|
||
resultat["prix_achat"] = float(prix_achat)
|
||
|
||
# ✅ 3. DESCRIPTION
|
||
if description:
|
||
resultat["description"] = description
|
||
|
||
# ✅ 4. CODE EAN
|
||
if code_ean:
|
||
resultat["code_ean"] = str(code_ean)
|
||
resultat["code_barre"] = str(code_ean)
|
||
|
||
# ✅ 5. FAMILLE
|
||
if famille_code_personnalise and famille_trouvee:
|
||
resultat["famille_code"] = famille_code_personnalise
|
||
try:
|
||
if famille_obj:
|
||
famille_obj.Read()
|
||
resultat["famille_libelle"] = getattr(
|
||
famille_obj, "FA_Intitule", ""
|
||
)
|
||
except:
|
||
pass
|
||
|
||
# ✅ 6. INFOS DÉPÔTS
|
||
if stocks_par_depot:
|
||
resultat["stocks_par_depot"] = stocks_par_depot
|
||
resultat["depot_principal"] = {
|
||
"code": depot_a_utiliser["code"],
|
||
"intitule": depot_a_utiliser["intitule"],
|
||
}
|
||
|
||
# ✅ 7. SUIVI DE STOCK
|
||
resultat["suivi_stock_active"] = stock_defini
|
||
|
||
# ✅ 8. AVERTISSEMENT SI STOCK NON DÉFINI
|
||
if has_stock_values and not stock_defini and stock_erreur:
|
||
resultat["avertissement"] = (
|
||
f"Stock demandé mais non défini dans F_ARTSTOCK : {stock_erreur}"
|
||
)
|
||
|
||
logger.info(
|
||
f"[EXTRACTION] ✅ Article extrait et enrichi avec {len(resultat)} champs"
|
||
)
|
||
|
||
return resultat
|
||
|
||
except ValueError:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
raise
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
|
||
logger.error(f"Erreur creation article : {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur creation article : {str(e)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Erreur globale : {e}", exc_info=True)
|
||
raise
|
||
|
||
def modifier_article(self, reference: str, article_data: Dict) -> Dict:
|
||
if not self.cial:
|
||
raise RuntimeError("Connexion Sage non établie")
|
||
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"[ARTICLE] === MODIFICATION {reference} ===")
|
||
|
||
# ========================================
|
||
# ÉTAPE 1 : CHARGER L'ARTICLE EXISTANT
|
||
# ========================================
|
||
factory_article = self.cial.FactoryArticle
|
||
persist = factory_article.ReadReference(reference.upper())
|
||
|
||
if not persist:
|
||
raise ValueError(f"Article {reference} introuvable")
|
||
|
||
article = win32com.client.CastTo(persist, "IBOArticle3")
|
||
article.Read()
|
||
|
||
designation_actuelle = getattr(article, "AR_Design", "")
|
||
logger.info(f"[ARTICLE] Trouvé : {reference} - {designation_actuelle}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : METTRE À JOUR LES CHAMPS
|
||
# ========================================
|
||
logger.info("[ARTICLE] Mise à jour des champs...")
|
||
|
||
champs_modifies = []
|
||
|
||
# ========================================
|
||
# 🆕 FAMILLE (NOUVEAU - avec scanner List)
|
||
# ========================================
|
||
if "famille" in article_data and article_data["famille"]:
|
||
famille_code_demande = article_data["famille"].upper().strip()
|
||
logger.info(
|
||
f"[FAMILLE] Changement demandé : {famille_code_demande}"
|
||
)
|
||
|
||
try:
|
||
# ========================================
|
||
# VÉRIFIER EXISTENCE VIA SQL
|
||
# ========================================
|
||
famille_existe_sql = False
|
||
famille_code_exact = None
|
||
famille_type = None
|
||
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT FA_CodeFamille, FA_Type
|
||
FROM F_FAMILLE
|
||
WHERE UPPER(FA_CodeFamille) = ?
|
||
""",
|
||
(famille_code_demande,),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
famille_code_exact = self._safe_strip(
|
||
row.FA_CodeFamille
|
||
)
|
||
famille_type = row.FA_Type if len(row) > 1 else 0
|
||
famille_existe_sql = True
|
||
|
||
# Vérifier le type
|
||
if famille_type == 1:
|
||
raise ValueError(
|
||
f"La famille '{famille_code_demande}' est de type 'Total' "
|
||
f"et ne peut pas contenir d'articles. "
|
||
f"Utilisez une famille de type Détail."
|
||
)
|
||
|
||
logger.info(
|
||
f" [SQL] Famille trouvée : {famille_code_exact} (type={famille_type})"
|
||
)
|
||
else:
|
||
raise ValueError(
|
||
f"Famille '{famille_code_demande}' introuvable dans Sage"
|
||
)
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning(f" [SQL] Erreur : {e}")
|
||
raise ValueError(f"Impossible de vérifier la famille : {e}")
|
||
|
||
# ========================================
|
||
# CHARGER VIA COM (SCANNER)
|
||
# ========================================
|
||
if famille_existe_sql and famille_code_exact:
|
||
logger.info(f" [COM] Recherche via scanner...")
|
||
|
||
factory_famille = self.cial.FactoryFamille
|
||
famille_obj = None
|
||
|
||
# Scanner List()
|
||
try:
|
||
index = 1
|
||
max_scan = 1000
|
||
|
||
while index <= max_scan:
|
||
try:
|
||
persist_test = factory_famille.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
fam_test = win32com.client.CastTo(
|
||
persist_test, "IBOFamille3"
|
||
)
|
||
fam_test.Read()
|
||
|
||
code_test = (
|
||
getattr(fam_test, "FA_CodeFamille", "")
|
||
.strip()
|
||
.upper()
|
||
)
|
||
|
||
if code_test == famille_code_exact.upper():
|
||
# TROUVÉ !
|
||
famille_obj = fam_test
|
||
logger.info(
|
||
f" [OK] Famille trouvée à l'index {index}"
|
||
)
|
||
break
|
||
|
||
index += 1
|
||
|
||
except Exception as e:
|
||
if "Accès refusé" in str(e) or "Access" in str(
|
||
e
|
||
):
|
||
break
|
||
index += 1
|
||
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" [COM] Scanner échoué : {str(e)[:200]}"
|
||
)
|
||
|
||
# Assigner la famille
|
||
if famille_obj:
|
||
famille_obj.Read()
|
||
article.Famille = famille_obj
|
||
champs_modifies.append(f"famille={famille_code_exact}")
|
||
logger.info(
|
||
f" [OK] Famille changée : {famille_code_exact}"
|
||
)
|
||
else:
|
||
raise ValueError(
|
||
f"Famille '{famille_code_demande}' trouvée en SQL mais inaccessible via COM. "
|
||
f"Essayez avec une autre famille."
|
||
)
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f" [ERREUR] Changement famille : {e}")
|
||
raise ValueError(f"Impossible de changer la famille : {str(e)}")
|
||
|
||
# ========================================
|
||
# DÉSIGNATION
|
||
# ========================================
|
||
if "designation" in article_data:
|
||
designation = str(article_data["designation"])[:69].strip()
|
||
article.AR_Design = designation
|
||
champs_modifies.append(f"designation")
|
||
logger.info(f" [OK] Désignation : {designation}")
|
||
|
||
# ========================================
|
||
# PRIX DE VENTE
|
||
# ========================================
|
||
if "prix_vente" in article_data:
|
||
try:
|
||
prix_vente = float(article_data["prix_vente"])
|
||
article.AR_PrixVen = prix_vente
|
||
champs_modifies.append("prix_vente")
|
||
logger.info(f" [OK] Prix vente : {prix_vente} EUR")
|
||
except Exception as e:
|
||
logger.warning(f" [WARN] Prix vente : {e}")
|
||
|
||
# ========================================
|
||
# PRIX D'ACHAT
|
||
# ========================================
|
||
if "prix_achat" in article_data:
|
||
try:
|
||
prix_achat = float(article_data["prix_achat"])
|
||
|
||
# Double tentative (AR_PrixAch / AR_PrixAchat)
|
||
try:
|
||
article.AR_PrixAch = prix_achat
|
||
champs_modifies.append("prix_achat")
|
||
logger.info(
|
||
f" [OK] Prix achat (AR_PrixAch) : {prix_achat} EUR"
|
||
)
|
||
except:
|
||
article.AR_PrixAchat = prix_achat
|
||
champs_modifies.append("prix_achat")
|
||
logger.info(
|
||
f" [OK] Prix achat (AR_PrixAchat) : {prix_achat} EUR"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(f" [WARN] Prix achat : {e}")
|
||
|
||
# ========================================
|
||
# STOCK RÉEL (NIVEAU ARTICLE)
|
||
# ========================================
|
||
if "stock_reel" in article_data:
|
||
try:
|
||
stock_reel = float(article_data["stock_reel"])
|
||
ancien_stock = float(getattr(article, "AR_Stock", 0.0))
|
||
|
||
article.AR_Stock = stock_reel
|
||
champs_modifies.append("stock_reel")
|
||
|
||
logger.info(f" [OK] Stock : {ancien_stock} -> {stock_reel}")
|
||
|
||
if stock_reel > ancien_stock:
|
||
logger.info(
|
||
f" [+] Stock augmenté de {stock_reel - ancien_stock}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f" [ERREUR] Stock : {e}")
|
||
raise ValueError(f"Impossible de modifier le stock: {e}")
|
||
|
||
# ========================================
|
||
# STOCK MINI/MAXI (NIVEAU ARTICLE)
|
||
# ========================================
|
||
if "stock_mini" in article_data:
|
||
try:
|
||
stock_mini = float(article_data["stock_mini"])
|
||
article.AR_StockMini = stock_mini
|
||
champs_modifies.append("stock_mini")
|
||
logger.info(f" [OK] Stock mini : {stock_mini}")
|
||
except Exception as e:
|
||
logger.warning(f" [WARN] Stock mini : {e}")
|
||
|
||
if "stock_maxi" in article_data:
|
||
try:
|
||
stock_maxi = float(article_data["stock_maxi"])
|
||
article.AR_StockMaxi = stock_maxi
|
||
champs_modifies.append("stock_maxi")
|
||
logger.info(f" [OK] Stock maxi : {stock_maxi}")
|
||
except Exception as e:
|
||
logger.warning(f" [WARN] Stock maxi : {e}")
|
||
|
||
# ========================================
|
||
# CODE EAN
|
||
# ========================================
|
||
if "code_ean" in article_data:
|
||
try:
|
||
code_ean = str(article_data["code_ean"])[:13].strip()
|
||
article.AR_CodeBarre = code_ean
|
||
champs_modifies.append("code_ean")
|
||
logger.info(f" [OK] Code EAN : {code_ean}")
|
||
except Exception as e:
|
||
logger.warning(f" [WARN] Code EAN : {e}")
|
||
|
||
# ========================================
|
||
# DESCRIPTION
|
||
# ========================================
|
||
if "description" in article_data:
|
||
try:
|
||
description = str(article_data["description"])[:255].strip()
|
||
article.AR_Commentaire = description
|
||
champs_modifies.append("description")
|
||
logger.info(f" [OK] Description définie")
|
||
except Exception as e:
|
||
logger.warning(f" [WARN] Description : {e}")
|
||
|
||
# ========================================
|
||
# VÉRIFICATION
|
||
# ========================================
|
||
if not champs_modifies:
|
||
logger.warning("[ARTICLE] Aucun champ à modifier")
|
||
return self._extraire_article(article)
|
||
|
||
logger.info(
|
||
f"[ARTICLE] Champs à modifier : {', '.join(champs_modifies)}"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉCRITURE
|
||
# ========================================
|
||
logger.info("[ARTICLE] Écriture des modifications...")
|
||
|
||
try:
|
||
article.Write()
|
||
logger.info("[ARTICLE] Write() réussi")
|
||
|
||
except Exception as e:
|
||
error_detail = str(e)
|
||
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_detail = (
|
||
f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
logger.error(f"[ARTICLE] Erreur Write() : {error_detail}")
|
||
raise RuntimeError(f"Échec modification : {error_detail}")
|
||
|
||
# ========================================
|
||
# RELECTURE ET EXTRACTION
|
||
# ========================================
|
||
article.Read()
|
||
|
||
logger.info(
|
||
f"[ARTICLE] MODIFIÉ : {reference} ({len(champs_modifies)} champs)"
|
||
)
|
||
|
||
# Extraction complète
|
||
resultat = self._extraire_article(article)
|
||
|
||
if not resultat:
|
||
# Fallback si extraction échoue
|
||
resultat = {
|
||
"reference": reference,
|
||
"designation": getattr(article, "AR_Design", ""),
|
||
}
|
||
|
||
# Enrichir avec les valeurs qu'on vient de modifier
|
||
if "prix_vente" in article_data:
|
||
resultat["prix_vente"] = float(article_data["prix_vente"])
|
||
|
||
if "prix_achat" in article_data:
|
||
resultat["prix_achat"] = float(article_data["prix_achat"])
|
||
|
||
if "stock_reel" in article_data:
|
||
resultat["stock_reel"] = float(article_data["stock_reel"])
|
||
|
||
if "stock_mini" in article_data:
|
||
resultat["stock_mini"] = float(article_data["stock_mini"])
|
||
|
||
if "stock_maxi" in article_data:
|
||
resultat["stock_maxi"] = float(article_data["stock_maxi"])
|
||
|
||
if "code_ean" in article_data:
|
||
resultat["code_ean"] = str(article_data["code_ean"])
|
||
resultat["code_barre"] = str(article_data["code_ean"])
|
||
|
||
if "description" in article_data:
|
||
resultat["description"] = str(article_data["description"])
|
||
|
||
if "famille" in article_data:
|
||
resultat["famille_code"] = (
|
||
famille_code_exact if "famille_code_exact" in locals() else ""
|
||
)
|
||
|
||
return resultat
|
||
|
||
except ValueError as e:
|
||
logger.error(f"[ARTICLE] Erreur métier : {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"[ARTICLE] Erreur technique : {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = f"Erreur Sage: {err.Description}"
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur technique Sage : {error_message}")
|
||
|
||
def creer_famille(self, famille_data: dict) -> dict:
|
||
"""
|
||
✅ Crée une nouvelle famille d'articles dans Sage 100c
|
||
|
||
**RESTRICTION : Seules les familles de type DÉTAIL peuvent être créées**
|
||
Les familles de type Total doivent être créées manuellement dans Sage.
|
||
|
||
Args:
|
||
famille_data: {
|
||
"code": str (obligatoire, max 18 car, ex: "ALIM"),
|
||
"intitule": str (obligatoire, max 69 car, ex: "Produits alimentaires"),
|
||
"type": int (IGNORÉ - toujours 0=Détail),
|
||
"compte_achat": str (optionnel, ex: "607000"),
|
||
"compte_vente": str (optionnel, ex: "707000")
|
||
}
|
||
|
||
Returns:
|
||
dict: Famille créée avec tous ses attributs
|
||
|
||
Raises:
|
||
ValueError: Si la famille existe déjà ou données invalides
|
||
RuntimeError: Si erreur technique Sage
|
||
"""
|
||
with self._com_context(), self._lock_com:
|
||
try:
|
||
logger.info("[FAMILLE] === CRÉATION FAMILLE (TYPE DÉTAIL) ===")
|
||
|
||
# ========================================
|
||
# VALIDATION
|
||
# ========================================
|
||
code = famille_data.get("code", "").upper().strip()
|
||
if not code:
|
||
raise ValueError("Le code famille est obligatoire")
|
||
|
||
if len(code) > 18:
|
||
raise ValueError(
|
||
"Le code famille ne peut pas dépasser 18 caractères"
|
||
)
|
||
|
||
intitule = famille_data.get("intitule", "").strip()
|
||
if not intitule:
|
||
raise ValueError("L'intitulé est obligatoire")
|
||
|
||
if len(intitule) > 69:
|
||
intitule = intitule[:69]
|
||
|
||
logger.info(f"[FAMILLE] Code : {code}")
|
||
logger.info(f"[FAMILLE] Intitulé : {intitule}")
|
||
|
||
# ✅ NOUVEAU : Avertir si l'utilisateur demande un type Total
|
||
type_demande = famille_data.get("type", 0)
|
||
if type_demande == 1:
|
||
logger.warning(
|
||
"[FAMILLE] Type 'Total' demandé mais IGNORÉ - Création en type Détail uniquement"
|
||
)
|
||
|
||
# ========================================
|
||
# VÉRIFIER SI EXISTE DÉJÀ
|
||
# ========================================
|
||
factory_famille = self.cial.FactoryFamille
|
||
|
||
try:
|
||
# Scanner pour vérifier l'existence
|
||
index = 1
|
||
while index <= 1000:
|
||
try:
|
||
persist_test = factory_famille.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
fam_test = win32com.client.CastTo(
|
||
persist_test, "IBOFamille3"
|
||
)
|
||
fam_test.Read()
|
||
|
||
code_existant = (
|
||
getattr(fam_test, "FA_CodeFamille", "").strip().upper()
|
||
)
|
||
|
||
if code_existant == code:
|
||
raise ValueError(f"La famille {code} existe déjà")
|
||
|
||
index += 1
|
||
except ValueError:
|
||
raise # Re-raise si c'est notre erreur
|
||
except:
|
||
index += 1
|
||
except ValueError:
|
||
raise
|
||
|
||
# ========================================
|
||
# CRÉER LA FAMILLE
|
||
# ========================================
|
||
persist = factory_famille.Create()
|
||
famille = win32com.client.CastTo(persist, "IBOFamille3")
|
||
famille.SetDefault()
|
||
|
||
# Champs obligatoires
|
||
famille.FA_CodeFamille = code
|
||
famille.FA_Intitule = intitule
|
||
|
||
# ✅ CRITIQUE : FORCER Type = 0 (Détail)
|
||
try:
|
||
famille.FA_Type = 0 # ✅ Toujours Détail
|
||
logger.info(f"[FAMILLE] Type : 0 (Détail)")
|
||
except Exception as e:
|
||
logger.warning(f"[FAMILLE] FA_Type non défini : {e}")
|
||
|
||
# Comptes généraux (optionnels)
|
||
compte_achat = famille_data.get("compte_achat")
|
||
if compte_achat:
|
||
try:
|
||
factory_compte = self.cial.CptaApplication.FactoryCompteG
|
||
persist_compte = factory_compte.ReadNumero(compte_achat)
|
||
|
||
if persist_compte:
|
||
compte_obj = win32com.client.CastTo(
|
||
persist_compte, "IBOCompteG3"
|
||
)
|
||
compte_obj.Read()
|
||
|
||
famille.CompteGAchat = compte_obj
|
||
logger.info(f"[FAMILLE] Compte achat : {compte_achat}")
|
||
except Exception as e:
|
||
logger.warning(f"[FAMILLE] Compte achat non défini : {e}")
|
||
|
||
compte_vente = famille_data.get("compte_vente")
|
||
if compte_vente:
|
||
try:
|
||
factory_compte = self.cial.CptaApplication.FactoryCompteG
|
||
persist_compte = factory_compte.ReadNumero(compte_vente)
|
||
|
||
if persist_compte:
|
||
compte_obj = win32com.client.CastTo(
|
||
persist_compte, "IBOCompteG3"
|
||
)
|
||
compte_obj.Read()
|
||
|
||
famille.CompteGVente = compte_obj
|
||
logger.info(f"[FAMILLE] Compte vente : {compte_vente}")
|
||
except Exception as e:
|
||
logger.warning(f"[FAMILLE] Compte vente non défini : {e}")
|
||
|
||
# ========================================
|
||
# ÉCRIRE DANS SAGE
|
||
# ========================================
|
||
logger.info("[FAMILLE] Écriture dans Sage...")
|
||
|
||
try:
|
||
famille.Write()
|
||
logger.info("[FAMILLE] Write() réussi")
|
||
except Exception as e:
|
||
error_detail = str(e)
|
||
|
||
try:
|
||
sage_error = self.cial.CptaApplication.LastError
|
||
if sage_error:
|
||
error_detail = (
|
||
f"{sage_error.Description} (Code: {sage_error.Number})"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
logger.error(f"[FAMILLE] Erreur Write() : {error_detail}")
|
||
raise RuntimeError(f"Échec création famille : {error_detail}")
|
||
|
||
# ========================================
|
||
# RELIRE ET RETOURNER
|
||
# ========================================
|
||
famille.Read()
|
||
|
||
resultat = {
|
||
"code": getattr(famille, "FA_CodeFamille", "").strip(),
|
||
"intitule": getattr(famille, "FA_Intitule", "").strip(),
|
||
"type": 0, # ✅ Toujours Détail
|
||
"type_libelle": "Détail",
|
||
}
|
||
|
||
logger.info(f"[FAMILLE] FAMILLE CRÉÉE : {code} - {intitule} (Détail)")
|
||
|
||
return resultat
|
||
|
||
except ValueError as e:
|
||
logger.error(f"[FAMILLE] Erreur métier : {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"[FAMILLE] Erreur technique : {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = f"Erreur Sage: {err.Description}"
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur technique Sage : {error_message}")
|
||
|
||
def lister_toutes_familles(
|
||
self, filtre: str = "", inclure_totaux: bool = False
|
||
) -> List[Dict]:
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
logger.info("[SQL] Détection des colonnes de F_FAMILLE...")
|
||
|
||
# Requête de test pour récupérer les métadonnées
|
||
cursor.execute("SELECT TOP 1 * FROM F_FAMILLE")
|
||
colonnes_disponibles = [column[0] for column in cursor.description]
|
||
|
||
logger.info(f"[SQL] Colonnes trouvées : {len(colonnes_disponibles)}")
|
||
|
||
colonnes_souhaitees = [
|
||
"FA_CodeFamille",
|
||
"FA_Intitule",
|
||
"FA_Type",
|
||
"FA_UniteVen",
|
||
"FA_Coef",
|
||
"FA_Central",
|
||
"FA_Nature",
|
||
"CG_NumAch",
|
||
"CG_NumVte",
|
||
"FA_Stat",
|
||
"FA_Raccourci",
|
||
]
|
||
|
||
# Ne garder QUE les colonnes qui existent vraiment
|
||
colonnes_a_lire = [
|
||
col for col in colonnes_souhaitees if col in colonnes_disponibles
|
||
]
|
||
|
||
if not colonnes_a_lire:
|
||
colonnes_a_lire = colonnes_disponibles
|
||
|
||
logger.info(f"[SQL] Colonnes sélectionnées : {len(colonnes_a_lire)}")
|
||
|
||
colonnes_str = ", ".join(colonnes_a_lire)
|
||
|
||
query = f"""
|
||
SELECT {colonnes_str}
|
||
FROM F_FAMILLE
|
||
WHERE 1=1
|
||
"""
|
||
|
||
params = []
|
||
|
||
if "FA_Type" in colonnes_disponibles:
|
||
if not inclure_totaux:
|
||
query += " AND FA_Type = 0" # ✅ Seulement Détail
|
||
logger.info("[SQL] Filtre : FA_Type = 0 (Détail uniquement)")
|
||
else:
|
||
logger.info("[SQL] Filtre : TOUS les types (Détail + Total)")
|
||
|
||
# Filtre texte (si fourni)
|
||
if filtre:
|
||
conditions_filtre = []
|
||
|
||
if "FA_CodeFamille" in colonnes_a_lire:
|
||
conditions_filtre.append("FA_CodeFamille LIKE ?")
|
||
params.append(f"%{filtre}%")
|
||
|
||
if "FA_Intitule" in colonnes_a_lire:
|
||
conditions_filtre.append("FA_Intitule LIKE ?")
|
||
params.append(f"%{filtre}%")
|
||
|
||
if conditions_filtre:
|
||
query += " AND (" + " OR ".join(conditions_filtre) + ")"
|
||
|
||
# Tri
|
||
if "FA_Intitule" in colonnes_a_lire:
|
||
query += " ORDER BY FA_Intitule"
|
||
elif "FA_CodeFamille" in colonnes_a_lire:
|
||
query += " ORDER BY FA_CodeFamille"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
familles = []
|
||
|
||
for row in rows:
|
||
famille = {}
|
||
|
||
# Remplir avec les colonnes disponibles
|
||
for idx, colonne in enumerate(colonnes_a_lire):
|
||
valeur = row[idx]
|
||
|
||
if isinstance(valeur, str):
|
||
valeur = valeur.strip()
|
||
|
||
famille[colonne] = valeur
|
||
|
||
# Alias
|
||
if "FA_CodeFamille" in famille:
|
||
famille["code"] = famille["FA_CodeFamille"]
|
||
|
||
if "FA_Intitule" in famille:
|
||
famille["intitule"] = famille["FA_Intitule"]
|
||
|
||
# Type lisible
|
||
if "FA_Type" in famille:
|
||
type_val = famille["FA_Type"]
|
||
famille["type"] = type_val
|
||
famille["type_libelle"] = "Total" if type_val == 1 else "Détail"
|
||
famille["est_total"] = type_val == 1
|
||
else:
|
||
famille["type"] = 0
|
||
famille["type_libelle"] = "Détail"
|
||
famille["est_total"] = False
|
||
|
||
# Autres champs
|
||
famille["unite_vente"] = str(famille.get("FA_UniteVen", ""))
|
||
famille["coef"] = (
|
||
float(famille.get("FA_Coef", 0.0))
|
||
if famille.get("FA_Coef") is not None
|
||
else 0.0
|
||
)
|
||
famille["compte_achat"] = famille.get("CG_NumAch", "")
|
||
famille["compte_vente"] = famille.get("CG_NumVte", "")
|
||
famille["est_statistique"] = (
|
||
(famille.get("FA_Stat") == 1) if "FA_Stat" in famille else False
|
||
)
|
||
famille["est_centrale"] = (
|
||
(famille.get("FA_Central") == 1)
|
||
if "FA_Central" in famille
|
||
else False
|
||
)
|
||
famille["nature"] = famille.get("FA_Nature", 0)
|
||
|
||
familles.append(famille)
|
||
|
||
type_msg = "DÉTAIL uniquement" if not inclure_totaux else "TOUS types"
|
||
logger.info(f"SQL: {len(familles)} familles chargées ({type_msg})")
|
||
|
||
return familles
|
||
|
||
except Exception as e:
|
||
logger.error(f"Erreur SQL familles: {e}", exc_info=True)
|
||
raise RuntimeError(f"Erreur lecture familles: {str(e)}")
|
||
|
||
def lire_famille(self, code: str) -> Dict:
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"[FAMILLE] Lecture : {code}")
|
||
|
||
code_recherche = code.upper().strip()
|
||
|
||
famille_existe_sql = False
|
||
famille_code_exact = None
|
||
famille_type_sql = None
|
||
famille_intitule_sql = None
|
||
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
# Détecter les colonnes disponibles
|
||
cursor.execute("SELECT TOP 1 * FROM F_FAMILLE")
|
||
colonnes_disponibles = [col[0] for col in cursor.description]
|
||
|
||
# Construire la requête selon les colonnes disponibles
|
||
colonnes_select = ["FA_CodeFamille", "FA_Intitule"]
|
||
|
||
if "FA_Type" in colonnes_disponibles:
|
||
colonnes_select.append("FA_Type")
|
||
|
||
colonnes_str = ", ".join(colonnes_select)
|
||
|
||
cursor.execute(
|
||
f"""
|
||
SELECT {colonnes_str}
|
||
FROM F_FAMILLE
|
||
WHERE UPPER(FA_CodeFamille) = ?
|
||
""",
|
||
(code_recherche,),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
famille_existe_sql = True
|
||
famille_code_exact = self._safe_strip(row.FA_CodeFamille)
|
||
famille_intitule_sql = self._safe_strip(row.FA_Intitule)
|
||
|
||
# Type (si disponible)
|
||
if "FA_Type" in colonnes_disponibles and len(row) > 2:
|
||
famille_type_sql = row.FA_Type
|
||
|
||
logger.info(
|
||
f" [SQL] Trouvée : {famille_code_exact} - {famille_intitule_sql} (type={famille_type_sql})"
|
||
)
|
||
else:
|
||
raise ValueError(f"Famille '{code}' introuvable dans Sage")
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning(f" [SQL] Erreur : {e}")
|
||
# Continuer quand même avec COM
|
||
|
||
if not famille_code_exact:
|
||
famille_code_exact = code_recherche
|
||
|
||
logger.info(
|
||
f" [COM] Recherche de '{famille_code_exact}' via scanner..."
|
||
)
|
||
|
||
factory_famille = self.cial.FactoryFamille
|
||
famille_obj = None
|
||
index_trouve = None
|
||
|
||
try:
|
||
index = 1
|
||
max_scan = 2000 # Scanner jusqu'à 2000 familles
|
||
|
||
while index <= max_scan:
|
||
try:
|
||
persist_test = factory_famille.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
fam_test = win32com.client.CastTo(
|
||
persist_test, "IBOFamille3"
|
||
)
|
||
fam_test.Read()
|
||
|
||
code_test = (
|
||
getattr(fam_test, "FA_CodeFamille", "").strip().upper()
|
||
)
|
||
|
||
if code_test == famille_code_exact:
|
||
# TROUVÉE !
|
||
famille_obj = fam_test
|
||
index_trouve = index
|
||
logger.info(f" [OK] Famille trouvée à l'index {index}")
|
||
break
|
||
|
||
index += 1
|
||
|
||
except Exception as e:
|
||
if "Accès refusé" in str(e) or "Access" in str(e):
|
||
break
|
||
index += 1
|
||
|
||
if not famille_obj:
|
||
if famille_existe_sql:
|
||
raise ValueError(
|
||
f"Famille '{code}' trouvée en SQL mais inaccessible via COM. "
|
||
f"Vérifiez les permissions."
|
||
)
|
||
else:
|
||
raise ValueError(f"Famille '{code}' introuvable")
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f" [COM] Erreur scanner : {e}")
|
||
raise RuntimeError(f"Erreur chargement famille : {str(e)}")
|
||
|
||
logger.info("[FAMILLE] Extraction des informations...")
|
||
|
||
famille_obj.Read()
|
||
|
||
# Champs de base
|
||
resultat = {
|
||
"code": getattr(famille_obj, "FA_CodeFamille", "").strip(),
|
||
"intitule": getattr(famille_obj, "FA_Intitule", "").strip(),
|
||
}
|
||
|
||
# Type
|
||
try:
|
||
fa_type = getattr(famille_obj, "FA_Type", 0)
|
||
resultat["type"] = fa_type
|
||
resultat["type_libelle"] = "Total" if fa_type == 1 else "Détail"
|
||
resultat["est_total"] = fa_type == 1
|
||
resultat["est_detail"] = fa_type == 0
|
||
|
||
# ⚠️ Avertissement si famille Total
|
||
if fa_type == 1:
|
||
resultat["avertissement"] = (
|
||
"Cette famille est de type 'Total' (agrégation comptable) "
|
||
"et ne peut pas contenir d'articles directement."
|
||
)
|
||
logger.warning(
|
||
f" [TYPE] Famille Total détectée : {resultat['code']}"
|
||
)
|
||
except:
|
||
resultat["type"] = 0
|
||
resultat["type_libelle"] = "Détail"
|
||
resultat["est_total"] = False
|
||
resultat["est_detail"] = True
|
||
|
||
# Unité de vente
|
||
try:
|
||
resultat["unite_vente"] = getattr(
|
||
famille_obj, "FA_UniteVen", ""
|
||
).strip()
|
||
except:
|
||
resultat["unite_vente"] = ""
|
||
|
||
# Coefficient
|
||
try:
|
||
coef = getattr(famille_obj, "FA_Coef", None)
|
||
resultat["coef"] = float(coef) if coef is not None else 0.0
|
||
except:
|
||
resultat["coef"] = 0.0
|
||
|
||
# Nature
|
||
try:
|
||
resultat["nature"] = getattr(famille_obj, "FA_Nature", 0)
|
||
except:
|
||
resultat["nature"] = 0
|
||
|
||
# Centrale d'achat
|
||
try:
|
||
central = getattr(famille_obj, "FA_Central", None)
|
||
resultat["est_centrale"] = (
|
||
(central == 1) if central is not None else False
|
||
)
|
||
except:
|
||
resultat["est_centrale"] = False
|
||
|
||
# Statistique
|
||
try:
|
||
stat = getattr(famille_obj, "FA_Stat", None)
|
||
resultat["est_statistique"] = (
|
||
(stat == 1) if stat is not None else False
|
||
)
|
||
except:
|
||
resultat["est_statistique"] = False
|
||
|
||
# Raccourci
|
||
try:
|
||
resultat["raccourci"] = getattr(
|
||
famille_obj, "FA_Raccourci", ""
|
||
).strip()
|
||
except:
|
||
resultat["raccourci"] = ""
|
||
|
||
# ========================================
|
||
# COMPTES GÉNÉRAUX
|
||
# ========================================
|
||
# Compte achat
|
||
try:
|
||
compte_achat_obj = getattr(famille_obj, "CompteGAchat", None)
|
||
if compte_achat_obj:
|
||
compte_achat_obj.Read()
|
||
resultat["compte_achat"] = getattr(
|
||
compte_achat_obj, "CG_Num", ""
|
||
).strip()
|
||
else:
|
||
resultat["compte_achat"] = ""
|
||
except:
|
||
resultat["compte_achat"] = ""
|
||
|
||
# Compte vente
|
||
try:
|
||
compte_vente_obj = getattr(famille_obj, "CompteGVente", None)
|
||
if compte_vente_obj:
|
||
compte_vente_obj.Read()
|
||
resultat["compte_vente"] = getattr(
|
||
compte_vente_obj, "CG_Num", ""
|
||
).strip()
|
||
else:
|
||
resultat["compte_vente"] = ""
|
||
except:
|
||
resultat["compte_vente"] = ""
|
||
|
||
# Index de lecture
|
||
resultat["index_com"] = index_trouve
|
||
|
||
# Dates (si disponibles)
|
||
try:
|
||
date_creation = getattr(famille_obj, "cbCreation", None)
|
||
resultat["date_creation"] = (
|
||
str(date_creation) if date_creation else ""
|
||
)
|
||
except:
|
||
resultat["date_creation"] = ""
|
||
|
||
try:
|
||
date_modif = getattr(famille_obj, "cbModification", None)
|
||
resultat["date_modification"] = (
|
||
str(date_modif) if date_modif else ""
|
||
)
|
||
except:
|
||
resultat["date_modification"] = ""
|
||
|
||
# Compter les articles de cette famille via SQL
|
||
try:
|
||
with self._get_sql_connection() as conn:
|
||
cursor = conn.cursor()
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM F_ARTICLE
|
||
WHERE FA_CodeFamille = ?
|
||
""",
|
||
(resultat["code"],),
|
||
)
|
||
|
||
row = cursor.fetchone()
|
||
if row:
|
||
resultat["nb_articles"] = row[0]
|
||
logger.info(
|
||
f" [STAT] {resultat['nb_articles']} article(s) dans cette famille"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f" [STAT] Impossible de compter les articles : {e}")
|
||
resultat["nb_articles"] = None
|
||
|
||
logger.info(
|
||
f"[FAMILLE] Lecture complète : {resultat['code']} - {resultat['intitule']}"
|
||
)
|
||
|
||
return resultat
|
||
|
||
except ValueError as e:
|
||
logger.error(f"[FAMILLE] Erreur métier : {e}")
|
||
raise
|
||
|
||
except Exception as e:
|
||
logger.error(f"[FAMILLE] Erreur technique : {e}", exc_info=True)
|
||
|
||
error_message = str(e)
|
||
if self.cial:
|
||
try:
|
||
err = self.cial.CptaApplication.LastError
|
||
if err:
|
||
error_message = f"Erreur Sage: {err.Description}"
|
||
except:
|
||
pass
|
||
|
||
raise RuntimeError(f"Erreur technique Sage : {error_message}")
|
||
|
||
def creer_entree_stock(self, entree_data: Dict) -> Dict:
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"[STOCK] === CRÉATION ENTRÉE STOCK (COM pur) ===")
|
||
|
||
# Démarrer transaction
|
||
transaction_active = False
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
transaction_active = True
|
||
logger.debug("Transaction démarrée")
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
# ========================================
|
||
# ÉTAPE 1 : CRÉER LE DOCUMENT D'ENTRÉE
|
||
# ========================================
|
||
factory_doc = self.cial.FactoryDocumentStock
|
||
persist_doc = factory_doc.CreateType(180) # 180 = Entrée
|
||
doc = win32com.client.CastTo(persist_doc, "IBODocumentStock3")
|
||
doc.SetDefault()
|
||
|
||
import pywintypes
|
||
|
||
date_mouv = entree_data.get("date_mouvement")
|
||
if isinstance(date_mouv, date):
|
||
doc.DO_Date = pywintypes.Time(
|
||
datetime.combine(date_mouv, datetime.min.time())
|
||
)
|
||
else:
|
||
doc.DO_Date = pywintypes.Time(datetime.now())
|
||
|
||
if entree_data.get("reference"):
|
||
doc.DO_Ref = entree_data["reference"]
|
||
|
||
doc.Write()
|
||
logger.info(f"[STOCK] Document créé")
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : PRÉPARER POUR LES STOCKS MINI/MAXI
|
||
# ========================================
|
||
factory_article = self.cial.FactoryArticle
|
||
factory_depot = self.cial.FactoryDepot
|
||
|
||
stocks_mis_a_jour = []
|
||
depot_principal = None
|
||
|
||
# Trouver un dépôt principal
|
||
try:
|
||
persist_depot = factory_depot.List(1)
|
||
if persist_depot:
|
||
depot_principal = win32com.client.CastTo(
|
||
persist_depot, "IBODepot3"
|
||
)
|
||
depot_principal.Read()
|
||
logger.info(
|
||
f"Dépôt principal: {getattr(depot_principal, 'DE_Code', '?')}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Erreur chargement dépôt: {e}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : TRAITER CHAQUE LIGNE (MOUVEMENT + STOCK)
|
||
# ========================================
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentStockLigne
|
||
|
||
for idx, ligne_data in enumerate(entree_data["lignes"], 1):
|
||
article_ref = ligne_data["article_ref"].upper()
|
||
quantite = ligne_data["quantite"]
|
||
stock_mini = ligne_data.get("stock_mini")
|
||
stock_maxi = ligne_data.get("stock_maxi")
|
||
|
||
logger.info(f"[STOCK] Ligne {idx}: {article_ref} x {quantite}")
|
||
|
||
# A. CHARGER L'ARTICLE
|
||
persist_article = factory_article.ReadReference(article_ref)
|
||
if not persist_article:
|
||
raise ValueError(f"Article {article_ref} introuvable")
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
# B. CRÉER LA LIGNE DE MOUVEMENT
|
||
ligne_persist = factory_lignes.Create()
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentStockLigne3"
|
||
)
|
||
|
||
ligne_obj.SetDefault()
|
||
|
||
# Lier l'article au mouvement
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
article_ref, float(quantite)
|
||
)
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(
|
||
article_obj, float(quantite)
|
||
)
|
||
except:
|
||
raise ValueError(
|
||
f"Impossible de lier l'article {article_ref}"
|
||
)
|
||
|
||
# Prix
|
||
prix = ligne_data.get("prix_unitaire")
|
||
if prix:
|
||
try:
|
||
ligne_obj.DL_PrixUnitaire = float(prix)
|
||
except:
|
||
pass
|
||
|
||
# Écrire la ligne
|
||
ligne_obj.Write()
|
||
|
||
# ========================================
|
||
# ÉTAPE 4 : GÉRER LES STOCKS MINI/MAXI (COM PUR)
|
||
# ========================================
|
||
if stock_mini is not None or stock_maxi is not None:
|
||
logger.info(
|
||
f"[STOCK] Ajustement stock pour {article_ref}..."
|
||
)
|
||
|
||
try:
|
||
# MÉTHODE A : Via Article.FactoryArticleStock (LA BONNE MÉTHODE)
|
||
logger.info(
|
||
f" [COM] Méthode A : Article.FactoryArticleStock"
|
||
)
|
||
|
||
# 1. Charger l'article COMPLET avec sa factory
|
||
factory_article = self.cial.FactoryArticle
|
||
persist_article_full = factory_article.ReadReference(
|
||
article_ref
|
||
)
|
||
article_full = win32com.client.CastTo(
|
||
persist_article_full, "IBOArticle3"
|
||
)
|
||
article_full.Read()
|
||
|
||
# 2. Accéder à la FactoryArticleStock de l'article
|
||
factory_article_stock = None
|
||
try:
|
||
factory_article_stock = (
|
||
article_full.FactoryArticleStock
|
||
)
|
||
logger.info(" ✅ FactoryArticleStock trouvée")
|
||
except AttributeError:
|
||
logger.warning(
|
||
" ❌ FactoryArticleStock non disponible"
|
||
)
|
||
|
||
if factory_article_stock:
|
||
# 3. Chercher si le stock existe déjà
|
||
stock_trouve = None
|
||
index_stock = 1
|
||
|
||
while index_stock <= 100:
|
||
try:
|
||
stock_persist = factory_article_stock.List(
|
||
index_stock
|
||
)
|
||
if stock_persist is None:
|
||
break
|
||
|
||
stock_obj = win32com.client.CastTo(
|
||
stock_persist, "IBOArticleStock3"
|
||
)
|
||
stock_obj.Read()
|
||
|
||
# Vérifier le dépôt
|
||
depot_stock = None
|
||
try:
|
||
depot_stock = getattr(
|
||
stock_obj, "Depot", None
|
||
)
|
||
if depot_stock:
|
||
depot_stock.Read()
|
||
depot_code = getattr(
|
||
depot_stock, "DE_Code", ""
|
||
).strip()
|
||
logger.debug(
|
||
f" Dépôt {index_stock}: {depot_code}"
|
||
)
|
||
|
||
# Si c'est le dépôt principal ou le premier trouvé
|
||
if (
|
||
not stock_trouve
|
||
or depot_code
|
||
== getattr(
|
||
depot_principal,
|
||
"DE_Code",
|
||
"",
|
||
)
|
||
):
|
||
stock_trouve = stock_obj
|
||
logger.info(
|
||
f" Stock trouvé pour dépôt {depot_code}"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
index_stock += 1
|
||
except Exception as e:
|
||
logger.debug(
|
||
f" Erreur stock {index_stock}: {e}"
|
||
)
|
||
index_stock += 1
|
||
|
||
# 4. Si pas trouvé, créer un nouveau stock
|
||
if not stock_trouve:
|
||
try:
|
||
stock_persist = (
|
||
factory_article_stock.Create()
|
||
)
|
||
stock_trouve = win32com.client.CastTo(
|
||
stock_persist, "IBOArticleStock3"
|
||
)
|
||
stock_trouve.SetDefault()
|
||
|
||
# Lier au dépôt principal si disponible
|
||
if depot_principal:
|
||
try:
|
||
stock_trouve.Depot = depot_principal
|
||
logger.info(
|
||
" Dépôt principal lié"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
logger.info(" Nouvel ArticleStock créé")
|
||
except Exception as e:
|
||
logger.error(
|
||
f" ❌ Impossible de créer ArticleStock: {e}"
|
||
)
|
||
raise
|
||
|
||
# 5. METTRE À JOUR LES STOCKS MINI/MAXI
|
||
if stock_trouve:
|
||
# Sauvegarder l'état avant modification
|
||
try:
|
||
stock_trouve.Read()
|
||
except:
|
||
pass
|
||
|
||
# STOCK MINI
|
||
if stock_mini is not None:
|
||
try:
|
||
# Essayer différentes propriétés possibles
|
||
for prop_name in [
|
||
"AS_QteMini",
|
||
"AS_Mini",
|
||
"AR_StockMini",
|
||
"StockMini",
|
||
]:
|
||
try:
|
||
setattr(
|
||
stock_trouve,
|
||
prop_name,
|
||
float(stock_mini),
|
||
)
|
||
logger.info(
|
||
f" ✅ Stock mini défini via {prop_name}: {stock_mini}"
|
||
)
|
||
break
|
||
except AttributeError:
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(
|
||
f" {prop_name} échoué: {e}"
|
||
)
|
||
continue
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" Stock mini non défini: {e}"
|
||
)
|
||
|
||
# STOCK MAXI
|
||
if stock_maxi is not None:
|
||
try:
|
||
# Essayer différentes propriétés possibles
|
||
for prop_name in [
|
||
"AS_QteMaxi",
|
||
"AS_Maxi",
|
||
"AR_StockMaxi",
|
||
"StockMaxi",
|
||
]:
|
||
try:
|
||
setattr(
|
||
stock_trouve,
|
||
prop_name,
|
||
float(stock_maxi),
|
||
)
|
||
logger.info(
|
||
f" ✅ Stock maxi défini via {prop_name}: {stock_maxi}"
|
||
)
|
||
break
|
||
except AttributeError:
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(
|
||
f" {prop_name} échoué: {e}"
|
||
)
|
||
continue
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" Stock maxi non défini: {e}"
|
||
)
|
||
|
||
# 6. SAUVEGARDER
|
||
try:
|
||
stock_trouve.Write()
|
||
logger.info(
|
||
f" ✅ ArticleStock sauvegardé"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f" ❌ Erreur Write() ArticleStock: {e}"
|
||
)
|
||
raise
|
||
|
||
# MÉTHODE B : Alternative via DepotStock si A échoue
|
||
if depot_principal and (
|
||
stock_mini is not None or stock_maxi is not None
|
||
):
|
||
logger.info(
|
||
f" [COM] Méthode B : Depot.FactoryDepotStock (alternative)"
|
||
)
|
||
|
||
try:
|
||
factory_depot_stock = None
|
||
for factory_name in [
|
||
"FactoryDepotStock",
|
||
"FactoryArticleStock",
|
||
]:
|
||
try:
|
||
factory_depot_stock = getattr(
|
||
depot_principal, factory_name, None
|
||
)
|
||
if factory_depot_stock:
|
||
logger.info(
|
||
f" Factory trouvée: {factory_name}"
|
||
)
|
||
break
|
||
except:
|
||
continue
|
||
|
||
if factory_depot_stock:
|
||
# Chercher le stock existant
|
||
stock_depot_trouve = None
|
||
index_ds = 1
|
||
|
||
while index_ds <= 100:
|
||
try:
|
||
stock_ds_persist = (
|
||
factory_depot_stock.List(
|
||
index_ds
|
||
)
|
||
)
|
||
if stock_ds_persist is None:
|
||
break
|
||
|
||
stock_ds = win32com.client.CastTo(
|
||
stock_ds_persist,
|
||
"IBODepotStock3",
|
||
)
|
||
stock_ds.Read()
|
||
|
||
ar_ref_ds = (
|
||
getattr(stock_ds, "AR_Ref", "")
|
||
.strip()
|
||
.upper()
|
||
)
|
||
if ar_ref_ds == article_ref:
|
||
stock_depot_trouve = stock_ds
|
||
break
|
||
|
||
index_ds += 1
|
||
except:
|
||
index_ds += 1
|
||
|
||
# Si pas trouvé, créer
|
||
if not stock_depot_trouve:
|
||
try:
|
||
stock_ds_persist = (
|
||
factory_depot_stock.Create()
|
||
)
|
||
stock_depot_trouve = (
|
||
win32com.client.CastTo(
|
||
stock_ds_persist,
|
||
"IBODepotStock3",
|
||
)
|
||
)
|
||
stock_depot_trouve.SetDefault()
|
||
stock_depot_trouve.AR_Ref = (
|
||
article_ref
|
||
)
|
||
logger.info(
|
||
" Nouveau DepotStock créé"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f" ❌ Impossible de créer DepotStock: {e}"
|
||
)
|
||
|
||
# Mettre à jour
|
||
if stock_depot_trouve:
|
||
if stock_mini is not None:
|
||
try:
|
||
stock_depot_trouve.AS_QteMini = float(
|
||
stock_mini
|
||
)
|
||
logger.info(
|
||
f" ✅ DepotStock.AS_QteMini = {stock_mini}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" DepotStock mini échoué: {e}"
|
||
)
|
||
|
||
if stock_maxi is not None:
|
||
try:
|
||
stock_depot_trouve.AS_QteMaxi = float(
|
||
stock_maxi
|
||
)
|
||
logger.info(
|
||
f" ✅ DepotStock.AS_QteMaxi = {stock_maxi}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f" DepotStock maxi échoué: {e}"
|
||
)
|
||
|
||
try:
|
||
stock_depot_trouve.Write()
|
||
logger.info(
|
||
" ✅ DepotStock sauvegardé"
|
||
)
|
||
except Exception as e:
|
||
logger.error(
|
||
f" ❌ DepotStock Write() échoué: {e}"
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.warning(f" Méthode B échouée: {e}")
|
||
|
||
except Exception as e:
|
||
logger.error(
|
||
f"[STOCK] Erreur ajustement stock: {e}",
|
||
exc_info=True,
|
||
)
|
||
# Ne pas bloquer si l'ajustement échoue
|
||
|
||
stocks_mis_a_jour.append(
|
||
{
|
||
"article_ref": article_ref,
|
||
"quantite_ajoutee": quantite,
|
||
"stock_mini_defini": stock_mini,
|
||
"stock_maxi_defini": stock_maxi,
|
||
}
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 5 : FINALISER LE DOCUMENT
|
||
# ========================================
|
||
doc.Write()
|
||
doc.Read()
|
||
|
||
numero = getattr(doc, "DO_Piece", "")
|
||
logger.info(f"[STOCK] ✅ Document finalisé: {numero}")
|
||
|
||
# ========================================
|
||
# ÉTAPE 6 : VÉRIFICATION VIA COM
|
||
# ========================================
|
||
logger.info(f"[STOCK] Vérification finale via COM...")
|
||
|
||
for stock_info in stocks_mis_a_jour:
|
||
article_ref = stock_info["article_ref"]
|
||
|
||
try:
|
||
# Recharger l'article pour voir les stocks
|
||
persist_article = factory_article.ReadReference(article_ref)
|
||
article_verif = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_verif.Read()
|
||
|
||
# Lire les attributs de stock
|
||
stock_total = 0.0
|
||
stock_mini_lu = 0.0
|
||
stock_maxi_lu = 0.0
|
||
|
||
# Essayer différents attributs
|
||
for attr in ["AR_Stock", "AS_QteSto", "Stock"]:
|
||
try:
|
||
val = getattr(article_verif, attr, None)
|
||
if val is not None:
|
||
stock_total = float(val)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
for attr in ["AR_StockMini", "AS_QteMini", "StockMini"]:
|
||
try:
|
||
val = getattr(article_verif, attr, None)
|
||
if val is not None:
|
||
stock_mini_lu = float(val)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
for attr in ["AR_StockMaxi", "AS_QteMaxi", "StockMaxi"]:
|
||
try:
|
||
val = getattr(article_verif, attr, None)
|
||
if val is not None:
|
||
stock_maxi_lu = float(val)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
logger.info(
|
||
f"[VERIF] {article_ref}: "
|
||
f"Total={stock_total}, "
|
||
f"Mini={stock_mini_lu}, "
|
||
f"Maxi={stock_maxi_lu}"
|
||
)
|
||
|
||
stock_info["stock_total_verifie"] = stock_total
|
||
stock_info["stock_mini_verifie"] = stock_mini_lu
|
||
stock_info["stock_maxi_verifie"] = stock_maxi_lu
|
||
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"[VERIF] Erreur vérification {article_ref}: {e}"
|
||
)
|
||
|
||
# Commit
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
logger.info(f"[STOCK] ✅ Transaction committée")
|
||
except:
|
||
logger.info(f"[STOCK] ✅ Changements sauvegardés")
|
||
|
||
return {
|
||
"article_ref": article_ref,
|
||
"numero": numero,
|
||
"type": 180,
|
||
"type_libelle": "Entrée en stock",
|
||
"date": str(getattr(doc, "DO_Date", "")),
|
||
"nb_lignes": len(stocks_mis_a_jour),
|
||
"stocks_mis_a_jour": stocks_mis_a_jour,
|
||
}
|
||
|
||
except Exception as e:
|
||
if transaction_active:
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
logger.info(f"[STOCK] ❌ Transaction annulée")
|
||
except:
|
||
pass
|
||
|
||
logger.error(f"[STOCK] ERREUR : {e}", exc_info=True)
|
||
raise ValueError(f"Erreur création entrée stock : {str(e)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True)
|
||
raise ValueError(f"Erreur création entrée stock : {str(e)}")
|
||
|
||
def lire_stock_article(self, reference: str, calcul_complet: bool = False) -> Dict:
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"[STOCK] Lecture stock : {reference}")
|
||
|
||
# Charger l'article
|
||
factory_article = self.cial.FactoryArticle
|
||
persist_article = factory_article.ReadReference(reference.upper())
|
||
|
||
if not persist_article:
|
||
raise ValueError(f"Article {reference} introuvable")
|
||
|
||
article = win32com.client.CastTo(persist_article, "IBOArticle3")
|
||
article.Read()
|
||
|
||
ar_suivi = getattr(article, "AR_SuiviStock", 0)
|
||
ar_design = getattr(article, "AR_Design", reference)
|
||
|
||
stock_info = {
|
||
"article": reference.upper(),
|
||
"designation": ar_design,
|
||
"stock_total": 0.0,
|
||
"suivi_stock": ar_suivi,
|
||
"suivi_libelle": {
|
||
0: "Aucun suivi",
|
||
1: "CMUP (sans lot)",
|
||
2: "FIFO/LIFO (avec lot)",
|
||
}.get(ar_suivi, f"Code {ar_suivi}"),
|
||
"depots": [],
|
||
"methode_lecture": None,
|
||
}
|
||
|
||
# ========================================
|
||
# MÉTHODE 1 : Via Depot.FactoryDepotStock (RAPIDE - 1-2 sec)
|
||
# ========================================
|
||
logger.info("[STOCK] Tentative Méthode 1 : Depot.FactoryDepotStock...")
|
||
|
||
try:
|
||
factory_depot = self.cial.FactoryDepot
|
||
index_depot = 1
|
||
stocks_trouves = []
|
||
|
||
# OPTIMISATION : Limiter à 20 dépôts max (au lieu de 100)
|
||
while index_depot <= 20:
|
||
try:
|
||
persist_depot = factory_depot.List(index_depot)
|
||
if persist_depot is None:
|
||
break
|
||
|
||
depot = win32com.client.CastTo(persist_depot, "IBODepot3")
|
||
depot.Read()
|
||
|
||
depot_code = ""
|
||
depot_intitule = ""
|
||
|
||
try:
|
||
depot_code = getattr(depot, "DE_Code", "").strip()
|
||
depot_intitule = getattr(
|
||
depot, "DE_Intitule", f"Dépôt {depot_code}"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
if not depot_code:
|
||
index_depot += 1
|
||
continue
|
||
|
||
# Chercher FactoryDepotStock
|
||
factory_depot_stock = None
|
||
|
||
for factory_name in [
|
||
"FactoryDepotStock",
|
||
"FactoryArticleStock",
|
||
]:
|
||
try:
|
||
factory_depot_stock = getattr(
|
||
depot, factory_name, None
|
||
)
|
||
if factory_depot_stock:
|
||
break
|
||
except:
|
||
pass
|
||
|
||
if factory_depot_stock:
|
||
# OPTIMISATION : Limiter le scan à 1000 stocks par dépôt
|
||
index_stock = 1
|
||
|
||
while index_stock <= 1000:
|
||
try:
|
||
stock_persist = factory_depot_stock.List(
|
||
index_stock
|
||
)
|
||
if stock_persist is None:
|
||
break
|
||
|
||
stock = win32com.client.CastTo(
|
||
stock_persist, "IBODepotStock3"
|
||
)
|
||
stock.Read()
|
||
|
||
# Vérifier si c'est notre article
|
||
article_ref_stock = ""
|
||
|
||
# Essayer différents attributs
|
||
for attr_ref in [
|
||
"AR_Ref",
|
||
"AS_Article",
|
||
"Article_Ref",
|
||
]:
|
||
try:
|
||
val = getattr(stock, attr_ref, None)
|
||
if val:
|
||
article_ref_stock = (
|
||
str(val).strip().upper()
|
||
)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
# Si pas trouvé, essayer via l'objet Article
|
||
if not article_ref_stock:
|
||
try:
|
||
article_obj = getattr(
|
||
stock, "Article", None
|
||
)
|
||
if article_obj:
|
||
article_obj.Read()
|
||
article_ref_stock = (
|
||
getattr(
|
||
article_obj, "AR_Ref", ""
|
||
)
|
||
.strip()
|
||
.upper()
|
||
)
|
||
except:
|
||
pass
|
||
|
||
if article_ref_stock == reference.upper():
|
||
# TROUVÉ !
|
||
quantite = 0.0
|
||
qte_mini = 0.0
|
||
qte_maxi = 0.0
|
||
|
||
# Essayer différents attributs de quantité
|
||
for attr_qte in [
|
||
"AS_QteSto",
|
||
"AS_Qte",
|
||
"QteSto",
|
||
"Quantite",
|
||
]:
|
||
try:
|
||
val = getattr(stock, attr_qte, None)
|
||
if val is not None:
|
||
quantite = float(val)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
# Qte mini/maxi
|
||
try:
|
||
qte_mini = float(
|
||
getattr(stock, "AS_QteMini", 0.0)
|
||
)
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
qte_maxi = float(
|
||
getattr(stock, "AS_QteMaxi", 0.0)
|
||
)
|
||
except:
|
||
pass
|
||
|
||
stocks_trouves.append(
|
||
{
|
||
"code": depot_code,
|
||
"intitule": depot_intitule,
|
||
"quantite": quantite,
|
||
"qte_mini": qte_mini,
|
||
"qte_maxi": qte_maxi,
|
||
}
|
||
)
|
||
|
||
logger.info(
|
||
f"[STOCK] Trouvé dépôt {depot_code} : {quantite} unités"
|
||
)
|
||
break
|
||
|
||
index_stock += 1
|
||
|
||
except Exception as e:
|
||
if "Accès refusé" in str(e):
|
||
break
|
||
index_stock += 1
|
||
|
||
index_depot += 1
|
||
|
||
except Exception as e:
|
||
if "Accès refusé" in str(e):
|
||
break
|
||
index_depot += 1
|
||
|
||
if stocks_trouves:
|
||
stock_info["depots"] = stocks_trouves
|
||
stock_info["stock_total"] = sum(
|
||
d["quantite"] for d in stocks_trouves
|
||
)
|
||
stock_info["methode_lecture"] = (
|
||
"Depot.FactoryDepotStock (RAPIDE)"
|
||
)
|
||
|
||
logger.info(
|
||
f"[STOCK] ✅ Méthode 1 réussie : {stock_info['stock_total']} unités"
|
||
)
|
||
return stock_info
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[STOCK] Méthode 1 échouée : {e}")
|
||
|
||
# ========================================
|
||
# MÉTHODE 2 : Via attributs Article (RAPIDE - < 1 sec)
|
||
# ========================================
|
||
logger.info("[STOCK] Tentative Méthode 2 : Attributs Article...")
|
||
|
||
try:
|
||
stock_trouve = False
|
||
|
||
# Essayer différents attributs
|
||
for attr_stock in ["AR_Stock", "AR_QteSto", "AR_QteStock"]:
|
||
try:
|
||
val = getattr(article, attr_stock, None)
|
||
if val is not None:
|
||
stock_info["stock_total"] = float(val)
|
||
stock_info["methode_lecture"] = (
|
||
f"Article.{attr_stock} (RAPIDE)"
|
||
)
|
||
stock_trouve = True
|
||
logger.info(
|
||
f"[STOCK] ✅ Méthode 2 réussie via {attr_stock}"
|
||
)
|
||
break
|
||
except:
|
||
pass
|
||
|
||
if stock_trouve:
|
||
return stock_info
|
||
|
||
except Exception as e:
|
||
logger.warning(f"[STOCK] Méthode 2 échouée : {e}")
|
||
|
||
# ========================================
|
||
# MÉTHODE 3 : Calcul depuis mouvements (LENT - DÉSACTIVÉ PAR DÉFAUT)
|
||
# ========================================
|
||
|
||
if not calcul_complet:
|
||
# Méthodes rapides ont échoué, mais calcul complet non demandé
|
||
logger.warning(
|
||
f"[STOCK] ⚠️ Méthodes rapides échouées pour {reference}"
|
||
)
|
||
|
||
stock_info["methode_lecture"] = "ÉCHEC - Méthodes rapides échouées"
|
||
stock_info["stock_total"] = 0.0
|
||
stock_info["note"] = (
|
||
"Les méthodes rapides de lecture de stock ont échoué. "
|
||
"Options disponibles :\n"
|
||
"1. Utiliser GET /sage/diagnostic/stock-rapide-test/{reference} pour un diagnostic détaillé\n"
|
||
"2. Appeler lire_stock_article(reference, calcul_complet=True) pour un calcul depuis mouvements (LENT - 20+ min)\n"
|
||
"3. Vérifier que l'article a bien un suivi de stock activé (AR_SuiviStock)"
|
||
)
|
||
|
||
return stock_info
|
||
|
||
# ⚠️ ATTENTION : Cette partie est ULTRA-LENTE (20+ minutes)
|
||
logger.warning(
|
||
"[STOCK] ⚠️ CALCUL DEPUIS MOUVEMENTS ACTIVÉ - PEUT PRENDRE 20+ MINUTES"
|
||
)
|
||
|
||
# [Le reste du code de calcul depuis mouvements reste inchangé...]
|
||
# ... (code existant pour la méthode 3)
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"[STOCK] Erreur lecture : {e}", exc_info=True)
|
||
raise ValueError(f"Erreur lecture stock : {str(e)}")
|
||
|
||
def verifier_stock_apres_mouvement(
|
||
self, article_ref: str, numero_mouvement: str
|
||
) -> Dict:
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(
|
||
f"[DEBUG] Vérification mouvement {numero_mouvement} pour {article_ref}"
|
||
)
|
||
|
||
diagnostic = {
|
||
"article_ref": article_ref.upper(),
|
||
"numero_mouvement": numero_mouvement,
|
||
"mouvement_trouve": False,
|
||
"ar_ref_dans_ligne": None,
|
||
"quantite_ligne": 0,
|
||
"stock_actuel": 0,
|
||
"problemes": [],
|
||
}
|
||
|
||
# ========================================
|
||
# 1. VÉRIFIER LE DOCUMENT
|
||
# ========================================
|
||
factory = self.cial.FactoryDocumentStock
|
||
|
||
persist = None
|
||
index = 1
|
||
|
||
while index < 10000:
|
||
try:
|
||
persist_test = factory.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
doc_test = win32com.client.CastTo(
|
||
persist_test, "IBODocumentStock3"
|
||
)
|
||
doc_test.Read()
|
||
|
||
if getattr(doc_test, "DO_Piece", "") == numero_mouvement:
|
||
persist = persist_test
|
||
diagnostic["mouvement_trouve"] = True
|
||
break
|
||
|
||
index += 1
|
||
except:
|
||
index += 1
|
||
|
||
if not persist:
|
||
diagnostic["problemes"].append(
|
||
f"Document {numero_mouvement} introuvable"
|
||
)
|
||
return diagnostic
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentStock3")
|
||
doc.Read()
|
||
|
||
# ========================================
|
||
# 2. VÉRIFIER LES LIGNES
|
||
# ========================================
|
||
try:
|
||
factory_lignes = getattr(doc, "FactoryDocumentLigne", None)
|
||
if not factory_lignes:
|
||
factory_lignes = getattr(doc, "FactoryDocumentStockLigne", None)
|
||
|
||
if factory_lignes:
|
||
idx = 1
|
||
while idx <= 100:
|
||
try:
|
||
ligne_p = factory_lignes.List(idx)
|
||
if ligne_p is None:
|
||
break
|
||
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
ligne.Read()
|
||
|
||
ar_ref_ligne = getattr(ligne, "AR_Ref", "").strip()
|
||
|
||
if ar_ref_ligne == article_ref.upper():
|
||
diagnostic["ar_ref_dans_ligne"] = ar_ref_ligne
|
||
diagnostic["quantite_ligne"] = float(
|
||
getattr(ligne, "DL_Qte", 0)
|
||
)
|
||
break
|
||
|
||
idx += 1
|
||
except:
|
||
idx += 1
|
||
except Exception as e:
|
||
diagnostic["problemes"].append(f"Erreur lecture lignes : {e}")
|
||
|
||
if not diagnostic["ar_ref_dans_ligne"]:
|
||
diagnostic["problemes"].append(
|
||
f"AR_Ref '{article_ref}' non trouvé dans les lignes du mouvement. "
|
||
f"L'article n'a pas été correctement lié."
|
||
)
|
||
|
||
# ========================================
|
||
# 3. LIRE LE STOCK ACTUEL
|
||
# ========================================
|
||
try:
|
||
stock_info = self.lire_stock_article(article_ref)
|
||
diagnostic["stock_actuel"] = stock_info["stock_total"]
|
||
except:
|
||
diagnostic["problemes"].append("Impossible de lire le stock actuel")
|
||
|
||
# ========================================
|
||
# 4. ANALYSE
|
||
# ========================================
|
||
if diagnostic["ar_ref_dans_ligne"] and diagnostic["stock_actuel"] == 0:
|
||
diagnostic["problemes"].append(
|
||
"PROBLÈME : L'article est dans la ligne du mouvement, "
|
||
"mais le stock n'a pas été mis à jour. Cela indique un problème "
|
||
"avec la méthode SetDefaultArticle() ou la configuration Sage."
|
||
)
|
||
|
||
return diagnostic
|
||
|
||
except Exception as e:
|
||
logger.error(f"[DEBUG] Erreur : {e}", exc_info=True)
|
||
raise
|
||
"""
|
||
📦 Lit le stock d'un article - VERSION CORRIGÉE
|
||
|
||
✅ CORRECTIONS :
|
||
1. Cherche d'abord via ArticleStock
|
||
2. Puis via DepotStock si disponible
|
||
3. Calcule le total même si aucun dépôt n'est trouvé
|
||
"""
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"[STOCK] Lecture stock article : {reference}")
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
persist = factory_article.ReadReference(reference.upper())
|
||
|
||
if not persist:
|
||
raise ValueError(f"Article {reference} introuvable")
|
||
|
||
article = win32com.client.CastTo(persist, "IBOArticle3")
|
||
article.Read()
|
||
|
||
# Infos de base
|
||
ar_suivi = getattr(article, "AR_SuiviStock", 0)
|
||
|
||
suivi_libelles = {
|
||
0: "Aucun",
|
||
1: "CMUP (sans lot)",
|
||
2: "FIFO/LIFO (avec lot)",
|
||
}
|
||
|
||
stock_info = {
|
||
"article": getattr(article, "AR_Ref", "").strip(),
|
||
"designation": getattr(article, "AR_Design", ""),
|
||
"stock_total": 0.0,
|
||
"suivi_stock": ar_suivi,
|
||
"suivi_libelle": suivi_libelles.get(ar_suivi, "Inconnu"),
|
||
"depots": [],
|
||
}
|
||
|
||
# ========================================
|
||
# MÉTHODE 1 : Via ArticleStock (global)
|
||
# ========================================
|
||
stock_global_trouve = False
|
||
|
||
try:
|
||
# Chercher dans ArticleStock (collection sur l'article)
|
||
if hasattr(article, "ArticleStock"):
|
||
article_stocks = article.ArticleStock
|
||
|
||
if article_stocks:
|
||
try:
|
||
nb_stocks = article_stocks.Count
|
||
logger.info(f" ArticleStock.Count = {nb_stocks}")
|
||
|
||
for i in range(1, nb_stocks + 1):
|
||
try:
|
||
stock_item = article_stocks.Item(i)
|
||
|
||
qte = float(
|
||
getattr(stock_item, "AS_QteSto", 0.0)
|
||
)
|
||
stock_info["stock_total"] += qte
|
||
|
||
depot_code = "?"
|
||
try:
|
||
depot_obj = getattr(
|
||
stock_item, "Depot", None
|
||
)
|
||
if depot_obj:
|
||
depot_obj.Read()
|
||
depot_code = getattr(
|
||
depot_obj, "DE_Code", "?"
|
||
)
|
||
except:
|
||
pass
|
||
|
||
stock_info["depots"].append(
|
||
{
|
||
"code": depot_code,
|
||
"quantite": qte,
|
||
"qte_mini": float(
|
||
getattr(
|
||
stock_item, "AS_QteMini", 0.0
|
||
)
|
||
),
|
||
"qte_maxi": float(
|
||
getattr(
|
||
stock_item, "AS_QteMaxi", 0.0
|
||
)
|
||
),
|
||
}
|
||
)
|
||
|
||
stock_global_trouve = True
|
||
except:
|
||
continue
|
||
except:
|
||
pass
|
||
except:
|
||
pass
|
||
|
||
# ========================================
|
||
# MÉTHODE 2 : Via FactoryDepotStock (si méthode 1 échoue)
|
||
# ========================================
|
||
if not stock_global_trouve:
|
||
logger.info(
|
||
" ArticleStock non disponible, essai FactoryDepotStock..."
|
||
)
|
||
|
||
try:
|
||
factory_depot = self.cial.FactoryDepot
|
||
|
||
# Scanner tous les dépôts
|
||
index_depot = 1
|
||
while index_depot <= 100:
|
||
try:
|
||
persist_depot = factory_depot.List(index_depot)
|
||
if persist_depot is None:
|
||
break
|
||
|
||
depot_obj = win32com.client.CastTo(
|
||
persist_depot, "IBODepot3"
|
||
)
|
||
depot_obj.Read()
|
||
|
||
depot_code = getattr(depot_obj, "DE_Code", "").strip()
|
||
|
||
# Chercher le stock dans ce dépôt
|
||
try:
|
||
factory_depot_stock = getattr(
|
||
depot_obj, "FactoryDepotStock", None
|
||
)
|
||
|
||
if factory_depot_stock:
|
||
index_stock = 1
|
||
while index_stock <= 1000:
|
||
try:
|
||
stock_persist = (
|
||
factory_depot_stock.List(
|
||
index_stock
|
||
)
|
||
)
|
||
if stock_persist is None:
|
||
break
|
||
|
||
stock = win32com.client.CastTo(
|
||
stock_persist, "IBODepotStock3"
|
||
)
|
||
stock.Read()
|
||
|
||
ar_ref_stock = getattr(
|
||
stock, "AR_Ref", ""
|
||
).strip()
|
||
|
||
if ar_ref_stock == reference.upper():
|
||
qte = float(
|
||
getattr(stock, "AS_QteSto", 0.0)
|
||
)
|
||
stock_info["stock_total"] += qte
|
||
|
||
stock_info["depots"].append(
|
||
{
|
||
"code": depot_code,
|
||
"quantite": qte,
|
||
"qte_mini": float(
|
||
getattr(
|
||
stock,
|
||
"AS_QteMini",
|
||
0.0,
|
||
)
|
||
),
|
||
"qte_maxi": float(
|
||
getattr(
|
||
stock,
|
||
"AS_QteMaxi",
|
||
0.0,
|
||
)
|
||
),
|
||
}
|
||
)
|
||
|
||
break
|
||
|
||
index_stock += 1
|
||
except:
|
||
index_stock += 1
|
||
except:
|
||
pass
|
||
|
||
index_depot += 1
|
||
except:
|
||
index_depot += 1
|
||
except:
|
||
pass
|
||
|
||
# ========================================
|
||
# RÉSULTAT FINAL
|
||
# ========================================
|
||
if not stock_info["depots"]:
|
||
logger.warning(f"[STOCK] {reference} : Aucun stock trouvé")
|
||
else:
|
||
logger.info(
|
||
f"[STOCK] {reference} : {stock_info['stock_total']} unités dans {len(stock_info['depots'])} dépôt(s)"
|
||
)
|
||
|
||
return stock_info
|
||
|
||
except Exception as e:
|
||
logger.error(f"[STOCK] Erreur lecture : {e}", exc_info=True)
|
||
raise
|
||
|
||
def creer_sortie_stock(self, sortie_data: Dict) -> Dict:
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
logger.info(f"[STOCK] === CRÉATION SORTIE STOCK ===")
|
||
logger.info(f"[STOCK] {len(sortie_data.get('lignes', []))} ligne(s)")
|
||
|
||
try:
|
||
self.cial.CptaApplication.BeginTrans()
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
# ========================================
|
||
# ÉTAPE 1 : CRÉER LE DOCUMENT
|
||
# ========================================
|
||
factory = self.cial.FactoryDocumentStock
|
||
persist = factory.CreateType(181) # 181 = Sortie
|
||
doc = win32com.client.CastTo(persist, "IBODocumentStock3")
|
||
doc.SetDefault()
|
||
|
||
# Date
|
||
import pywintypes
|
||
|
||
date_mouv = sortie_data.get("date_mouvement")
|
||
if isinstance(date_mouv, date):
|
||
doc.DO_Date = pywintypes.Time(
|
||
datetime.combine(date_mouv, datetime.min.time())
|
||
)
|
||
else:
|
||
doc.DO_Date = pywintypes.Time(datetime.now())
|
||
|
||
# Référence
|
||
if sortie_data.get("reference"):
|
||
doc.DO_Ref = sortie_data["reference"]
|
||
|
||
doc.Write()
|
||
logger.info(
|
||
f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}"
|
||
)
|
||
|
||
# ========================================
|
||
# ÉTAPE 2 : FACTORY LIGNES
|
||
# ========================================
|
||
try:
|
||
factory_lignes = doc.FactoryDocumentLigne
|
||
except:
|
||
factory_lignes = doc.FactoryDocumentStockLigne
|
||
|
||
factory_article = self.cial.FactoryArticle
|
||
stocks_mis_a_jour = []
|
||
|
||
# ========================================
|
||
# ÉTAPE 3 : TRAITER CHAQUE LIGNE
|
||
# ========================================
|
||
for idx, ligne_data in enumerate(sortie_data["lignes"], 1):
|
||
article_ref = ligne_data["article_ref"].upper()
|
||
quantite = ligne_data["quantite"]
|
||
|
||
logger.info(
|
||
f"[STOCK] ======== LIGNE {idx} : {article_ref} x {quantite} ========"
|
||
)
|
||
|
||
# Charger l'article
|
||
persist_article = factory_article.ReadReference(article_ref)
|
||
if not persist_article:
|
||
raise ValueError(f"Article {article_ref} introuvable")
|
||
|
||
article_obj = win32com.client.CastTo(
|
||
persist_article, "IBOArticle3"
|
||
)
|
||
article_obj.Read()
|
||
|
||
ar_suivi = getattr(article_obj, "AR_SuiviStock", 0)
|
||
ar_design = getattr(article_obj, "AR_Design", article_ref)
|
||
|
||
logger.info(f"[STOCK] Article : {ar_design}")
|
||
logger.info(f"[STOCK] AR_SuiviStock : {ar_suivi}")
|
||
|
||
# ⚠️ VÉRIFIER LE STOCK DISPONIBLE
|
||
stock_dispo = self.verifier_stock_suffisant(
|
||
article_ref, quantite, None
|
||
)
|
||
if not stock_dispo["suffisant"]:
|
||
raise ValueError(
|
||
f"Stock insuffisant pour {article_ref} : "
|
||
f"disponible={stock_dispo['stock_disponible']}, "
|
||
f"demandé={quantite}"
|
||
)
|
||
|
||
logger.info(
|
||
f"[STOCK] Stock disponible : {stock_dispo['stock_disponible']}"
|
||
)
|
||
|
||
# Gérer le lot
|
||
numero_lot = ligne_data.get("numero_lot")
|
||
|
||
if ar_suivi == 1: # CMUP
|
||
if numero_lot:
|
||
logger.warning(f"[STOCK] CMUP : Suppression du lot")
|
||
numero_lot = None
|
||
|
||
elif ar_suivi == 2: # FIFO/LIFO
|
||
if not numero_lot:
|
||
import uuid
|
||
|
||
numero_lot = f"AUTO-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6].upper()}"
|
||
logger.info(
|
||
f"[STOCK] FIFO/LIFO : Lot auto-généré '{numero_lot}'"
|
||
)
|
||
|
||
# ========================================
|
||
# CRÉER LA LIGNE
|
||
# ========================================
|
||
ligne_persist = factory_lignes.Create()
|
||
|
||
try:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne_obj = win32com.client.CastTo(
|
||
ligne_persist, "IBODocumentStockLigne3"
|
||
)
|
||
|
||
ligne_obj.SetDefault()
|
||
|
||
# ========================================
|
||
# LIAISON ARTICLE
|
||
# ========================================
|
||
article_lie = False
|
||
|
||
try:
|
||
ligne_obj.SetDefaultArticleReference(
|
||
article_ref, float(quantite)
|
||
)
|
||
article_lie = True
|
||
logger.info(f"[STOCK] ✅ SetDefaultArticleReference()")
|
||
except:
|
||
try:
|
||
ligne_obj.SetDefaultArticle(
|
||
article_obj, float(quantite)
|
||
)
|
||
article_lie = True
|
||
logger.info(f"[STOCK] ✅ SetDefaultArticle()")
|
||
except:
|
||
pass
|
||
|
||
if not article_lie:
|
||
raise ValueError(
|
||
f"Impossible de lier l'article {article_ref}"
|
||
)
|
||
|
||
# ========================================
|
||
# LOT (si FIFO/LIFO)
|
||
# ========================================
|
||
if numero_lot and ar_suivi == 2:
|
||
try:
|
||
ligne_obj.SetDefaultLot(numero_lot)
|
||
logger.info(f"[STOCK] ✅ Lot défini")
|
||
except:
|
||
try:
|
||
ligne_obj.LS_NoSerie = numero_lot
|
||
logger.info(f"[STOCK] ✅ Lot via LS_NoSerie")
|
||
except:
|
||
pass
|
||
|
||
# Prix
|
||
prix = ligne_data.get("prix_unitaire")
|
||
if prix:
|
||
try:
|
||
ligne_obj.DL_PrixUnitaire = float(prix)
|
||
except:
|
||
pass
|
||
|
||
# ========================================
|
||
# ÉCRIRE LA LIGNE
|
||
# ========================================
|
||
ligne_obj.Write()
|
||
logger.info(f"[STOCK] ✅ Write() réussi")
|
||
|
||
# Vérification
|
||
ligne_obj.Read()
|
||
ref_verifiee = article_ref # Supposer OK si Write() réussi
|
||
|
||
try:
|
||
article_lie_obj = getattr(ligne_obj, "Article", None)
|
||
if article_lie_obj:
|
||
article_lie_obj.Read()
|
||
ref_verifiee = (
|
||
getattr(article_lie_obj, "AR_Ref", "").strip()
|
||
or article_ref
|
||
)
|
||
except:
|
||
pass
|
||
|
||
logger.info(f"[STOCK] ✅ LIGNE {idx} COMPLÈTE")
|
||
|
||
stocks_mis_a_jour.append(
|
||
{
|
||
"article_ref": article_ref,
|
||
"quantite_retiree": quantite,
|
||
"reference_verifiee": ref_verifiee,
|
||
"stock_avant": stock_dispo["stock_disponible"],
|
||
"stock_apres": stock_dispo["stock_apres"],
|
||
"numero_lot": numero_lot if ar_suivi == 2 else None,
|
||
}
|
||
)
|
||
|
||
# ========================================
|
||
# FINALISER
|
||
# ========================================
|
||
doc.Write()
|
||
doc.Read()
|
||
|
||
numero = getattr(doc, "DO_Piece", "")
|
||
logger.info(f"[STOCK] ✅ Document finalisé : {numero}")
|
||
|
||
# Commit
|
||
try:
|
||
self.cial.CptaApplication.CommitTrans()
|
||
logger.info(f"[STOCK] ✅ Transaction committée")
|
||
except:
|
||
pass
|
||
|
||
return {
|
||
"numero": numero,
|
||
"type": 1,
|
||
"date": str(getattr(doc, "DO_Date", "")),
|
||
"nb_lignes": len(stocks_mis_a_jour),
|
||
"reference": sortie_data.get("reference"),
|
||
"stocks_mis_a_jour": stocks_mis_a_jour,
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"[STOCK] ERREUR : {e}", exc_info=True)
|
||
try:
|
||
self.cial.CptaApplication.RollbackTrans()
|
||
except:
|
||
pass
|
||
raise ValueError(f"Erreur création sortie stock : {str(e)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"[STOCK] ERREUR GLOBALE : {e}", exc_info=True)
|
||
raise ValueError(f"Erreur création sortie stock : {str(e)}")
|
||
|
||
def lire_mouvement_stock(self, numero: str) -> Dict:
|
||
try:
|
||
with self._com_context(), self._lock_com:
|
||
factory = self.cial.FactoryDocumentStock
|
||
|
||
# Chercher le document
|
||
persist = None
|
||
index = 1
|
||
|
||
logger.info(f"[MOUVEMENT] Recherche de {numero}...")
|
||
|
||
while index < 10000:
|
||
try:
|
||
persist_test = factory.List(index)
|
||
if persist_test is None:
|
||
break
|
||
|
||
doc_test = win32com.client.CastTo(
|
||
persist_test, "IBODocumentStock3"
|
||
)
|
||
doc_test.Read()
|
||
|
||
if getattr(doc_test, "DO_Piece", "") == numero:
|
||
persist = persist_test
|
||
logger.info(f"[MOUVEMENT] Trouvé à l'index {index}")
|
||
break
|
||
|
||
index += 1
|
||
except:
|
||
index += 1
|
||
|
||
if not persist:
|
||
raise ValueError(f"Mouvement {numero} introuvable")
|
||
|
||
doc = win32com.client.CastTo(persist, "IBODocumentStock3")
|
||
doc.Read()
|
||
|
||
# Infos du document
|
||
do_type = getattr(doc, "DO_Type", -1)
|
||
|
||
types_mouvements = {
|
||
180: "Entrée",
|
||
181: "Sortie",
|
||
182: "Transfert",
|
||
183: "Inventaire",
|
||
}
|
||
|
||
mouvement = {
|
||
"numero": numero,
|
||
"type": do_type,
|
||
"type_libelle": types_mouvements.get(do_type, f"Type {do_type}"),
|
||
"date": str(getattr(doc, "DO_Date", "")),
|
||
"reference": getattr(doc, "DO_Ref", ""),
|
||
"lignes": [],
|
||
}
|
||
|
||
# Lire les lignes
|
||
try:
|
||
factory_lignes = getattr(
|
||
doc, "FactoryDocumentLigne", None
|
||
) or getattr(doc, "FactoryDocumentStockLigne", None)
|
||
|
||
if factory_lignes:
|
||
idx = 1
|
||
while idx <= 100:
|
||
try:
|
||
ligne_p = factory_lignes.List(idx)
|
||
if ligne_p is None:
|
||
break
|
||
|
||
try:
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentLigne3"
|
||
)
|
||
except:
|
||
ligne = win32com.client.CastTo(
|
||
ligne_p, "IBODocumentStockLigne3"
|
||
)
|
||
|
||
ligne.Read()
|
||
|
||
# Récupérer la référence article via l'objet Article
|
||
article_ref = ""
|
||
try:
|
||
article_obj = getattr(ligne, "Article", None)
|
||
if article_obj:
|
||
article_obj.Read()
|
||
article_ref = getattr(
|
||
article_obj, "AR_Ref", ""
|
||
).strip()
|
||
except:
|
||
pass
|
||
|
||
ligne_info = {
|
||
"article_ref": article_ref,
|
||
"designation": getattr(ligne, "DL_Design", ""),
|
||
"quantite": float(getattr(ligne, "DL_Qte", 0.0)),
|
||
"prix_unitaire": float(
|
||
getattr(ligne, "DL_PrixUnitaire", 0.0)
|
||
),
|
||
"montant_ht": float(
|
||
getattr(ligne, "DL_MontantHT", 0.0)
|
||
),
|
||
"numero_lot": getattr(ligne, "LS_NoSerie", ""),
|
||
}
|
||
|
||
mouvement["lignes"].append(ligne_info)
|
||
|
||
idx += 1
|
||
except:
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"[MOUVEMENT] Erreur lecture lignes : {e}")
|
||
|
||
mouvement["nb_lignes"] = len(mouvement["lignes"])
|
||
|
||
logger.info(
|
||
f"[MOUVEMENT] Lu : {numero} - {mouvement['nb_lignes']} ligne(s)"
|
||
)
|
||
|
||
return mouvement
|
||
|
||
except ValueError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"[MOUVEMENT] Erreur : {e}", exc_info=True)
|
||
raise ValueError(f"Erreur lecture mouvement : {str(e)}")
|
||
|
||
def verifier_stock_suffisant(self, article_ref, quantite_demandee, depot_code=None):
|
||
try:
|
||
stock_info = self.lire_stock_article(article_ref)
|
||
|
||
if depot_code:
|
||
# Vérifier dans un dépôt spécifique
|
||
depot_trouve = next(
|
||
(d for d in stock_info["depots"] if d["code"] == depot_code), None
|
||
)
|
||
|
||
if not depot_trouve:
|
||
return {
|
||
"suffisant": False,
|
||
"stock_disponible": 0.0,
|
||
"quantite_demandee": quantite_demandee,
|
||
"stock_apres": -quantite_demandee,
|
||
"erreur": f"Article non présent dans le dépôt {depot_code}",
|
||
}
|
||
|
||
stock_dispo = depot_trouve["quantite"]
|
||
else:
|
||
# Vérifier sur le stock total
|
||
stock_dispo = stock_info["stock_total"]
|
||
|
||
suffisant = stock_dispo >= quantite_demandee
|
||
stock_apres = stock_dispo - quantite_demandee
|
||
|
||
return {
|
||
"suffisant": suffisant,
|
||
"stock_disponible": stock_dispo,
|
||
"quantite_demandee": quantite_demandee,
|
||
"stock_apres": stock_apres,
|
||
"depot": depot_code or "TOUS",
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Erreur vérification stock : {e}")
|
||
raise
|