From a729b812eb8deedcac2ba63fefca3beb4a7012e8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 17 Dec 2025 17:08:37 +0300 Subject: [PATCH] feat(stock): add lot number and stock min/max validation to movement line --- main.py | 48 +- sage_connector.py | 1429 ++++++++++++++++++++++++--------------------- 2 files changed, 815 insertions(+), 662 deletions(-) diff --git a/main.py b/main.py index 79d89d4..8aa4430 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, HTTPException, Header, Depends, Query from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, validator from typing import Optional, List, Dict from datetime import datetime, date from enum import Enum @@ -234,8 +234,6 @@ class ArticleUpdateGatewayRequest(BaseModel): class MouvementStockLigneRequest(BaseModel): - """Ligne de mouvement de stock""" - article_ref: str = Field(..., description="Référence de l'article") quantite: float = Field(..., gt=0, description="Quantité (>0)") depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") @@ -243,6 +241,50 @@ class MouvementStockLigneRequest(BaseModel): None, ge=0, description="Prix unitaire (optionnel)" ) commentaire: Optional[str] = Field(None, description="Commentaire ligne") + numero_lot: Optional[str] = Field( + None, description="Numéro de lot (pour FIFO/LIFO)" + ) + stock_mini: Optional[float] = Field( + None, + ge=0, + description="""Stock minimum à définir pour cet article. + Si fourni, met à jour AS_QteMini dans F_ARTSTOCK. + Laisser None pour ne pas modifier.""", + ) + stock_maxi: Optional[float] = Field( + None, + ge=0, + description="""Stock maximum à définir pour cet article. + Doit être > stock_mini si les deux sont fournis.""", + ) + + class Config: + schema_extra = { + "example": { + "article_ref": "ARTS-001", + "quantite": 50.0, + "depot_code": "01", + "prix_unitaire": 100.0, + "commentaire": "Réapprovisionnement", + "numero_lot": "LOT20241217", + "stock_mini": 10.0, + "stock_maxi": 200.0, + } + } + + @validator("stock_maxi") + def validate_stock_maxi(cls, v, values): + """Valide que stock_maxi > stock_mini si les deux sont fournis""" + if ( + v is not None + and "stock_mini" in values + and values["stock_mini"] is not None + ): + if v <= values["stock_mini"]: + raise ValueError( + "stock_maxi doit être strictement supérieur à stock_mini" + ) + return v class EntreeStockRequest(BaseModel): diff --git a/sage_connector.py b/sage_connector.py index 02dd5c2..9859834 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -870,7 +870,7 @@ class SageConnector: cursor = conn.cursor() # ======================================== - # ÉTAPE 1 : LIRE LES ARTICLES (BASE) + # ÉTAPE 1 : LIRE LES ARTICLES DE BASE # ======================================== query = """ SELECT @@ -907,106 +907,100 @@ class SageConnector: "est_actif": (row[6] == 0), "code_ean": self._safe_strip(row[7]), "type_article": row[8] if row[8] is not None else 0, - # Valeurs par défaut + # ✅ 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 LE STOCK (si demandé) + # ÉTAPE 2 : ENRICHIR AVEC STOCK DEPUIS F_ARTSTOCK (OBLIGATOIRE) # ======================================== if avec_stock and articles: logger.info( - f"📦 Enrichissement stock pour {len(articles)} articles..." + f"📦 Enrichissement stock depuis F_ARTSTOCK pour {len(articles)} articles..." ) - # Essayer différentes tables de stock (selon version Sage) - tables_stock = ["F_ARTSTOCK", "F_DEPOTSTOCK", "F_STOCK"] - table_utilisee = None + try: + # Créer un mapping des références + references = [ + a["reference"] for a in articles if a["reference"] + ] - for table in tables_stock: - try: - # Test si la table existe - cursor.execute(f"SELECT TOP 1 * FROM {table}") - table_utilisee = table - logger.info(f" ✅ Table de stock détectée : {table}") - break - except: - continue + if 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() - if table_utilisee: - # Construire un mapping référence -> stock 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 + ), + } - try: - # ✅ CORRECTION : Requête adaptée selon la table avec les BONS noms de colonnes - if table_utilisee == "F_ARTSTOCK": - stock_query = """ - SELECT - AR_Ref, - SUM(ISNULL(AS_QteSto, 0)) as Stock_Total, - MIN(ISNULL(AS_QteMini, 0)) as Stock_Mini, - MAX(ISNULL(AS_QteMaxi, 0)) as Stock_Maxi - FROM F_ARTSTOCK - GROUP BY AR_Ref - """ - elif table_utilisee == "F_DEPOTSTOCK": - stock_query = """ - SELECT - AR_Ref, - SUM(ISNULL(DS_QteSto, 0)) as Stock_Total, - MIN(ISNULL(DS_QteMini, 0)) as Stock_Mini, - MAX(ISNULL(DS_QteMaxi, 0)) as Stock_Maxi - FROM F_DEPOTSTOCK - GROUP BY AR_Ref - """ - else: # F_STOCK ou autre - stock_query = f""" - SELECT - AR_Ref, - SUM(ISNULL(Quantite, 0)) as Stock_Total, - 0 as Stock_Mini, - 0 as Stock_Maxi - FROM {table_utilisee} - GROUP BY AR_Ref - """ + logger.info( + f"✅ Stocks chargés pour {len(stock_map)} articles depuis F_ARTSTOCK" + ) - cursor.execute(stock_query) - stock_rows = cursor.fetchall() + # 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 - for stock_row in stock_rows: - ref = self._safe_strip(stock_row[0]) - if ref: - stock_map[ref] = { - "stock_reel": ( - float(stock_row[1]) if stock_row[1] else 0.0 - ), - "stock_mini": ( - float(stock_row[2]) if stock_row[2] else 0.0 - ), - "stock_maxi": ( - float(stock_row[3]) if stock_row[3] else 0.0 - ), - } - - logger.info( - f" ✅ Stocks chargés pour {len(stock_map)} articles" - ) - - # Enrichir les articles - for article in articles: - if article["reference"] in stock_map: - article.update(stock_map[article["reference"]]) - - except Exception as e: - logger.warning( - f" ⚠️ Erreur lecture stocks depuis {table_utilisee}: {e}" - ) - else: - logger.warning(" ⚠️ Aucune table de stock trouvée") + 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'})" @@ -1014,7 +1008,7 @@ class SageConnector: return articles except Exception as e: - logger.error(f"❌ Erreur SQL articles: {e}") + logger.error(f"❌ Erreur SQL articles: {e}", exc_info=True) raise RuntimeError(f"Erreur lecture articles: {str(e)}") def lire_article(self, reference): @@ -1022,7 +1016,6 @@ class SageConnector: with self._get_sql_connection() as conn: cursor = conn.cursor() - # ✅ MÊME REQUÊTE que lister_tous_articles (colonnes existantes uniquement) cursor.execute( """ SELECT @@ -1031,7 +1024,7 @@ class SageConnector: AR_CodeBarre, AR_Type FROM F_ARTICLE WHERE AR_Ref = ? - """, + """, (reference.upper(),), ) @@ -1049,9 +1042,22 @@ class SageConnector: "famille_code": self._safe_strip(row[5]), "est_actif": (row[6] == 0), "code_ean": self._safe_strip(row[7]), - "code_barre": self._safe_strip(row[7]), # Même valeur que code_ean + "code_barre": self._safe_strip(row[7]), "type_article": row[8] if row[8] is not None else 0, - # Valeurs par défaut pour le stock + "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, @@ -1060,23 +1066,34 @@ class SageConnector: "stock_disponible": 0.0, } - # ✅ Enrichir avec les stocks (MÊME logique que lister_tous_articles) + # 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 + 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 ) @@ -1086,12 +1103,55 @@ class SageConnector: article["stock_maxi"] = ( float(stock_row[2]) if stock_row[2] else 0.0 ) - article["stock_disponible"] = article["stock_reel"] # Simplifié + + # 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.warning( - f"⚠️ Impossible de lire le stock pour {reference}: {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 @@ -7426,8 +7486,16 @@ class SageConnector: 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À @@ -7463,7 +7531,7 @@ class SageConnector: article.AR_Design = designation # ======================================== - # ÉTAPE 4 : TROUVER ARTICLE MODÈLE VIA SQL (RAPIDE) + # ÉTAPE 4 : TROUVER ARTICLE MODÈLE VIA SQL # ======================================== logger.info("[MODELE] Recherche article modèle via SQL...") @@ -7519,13 +7587,12 @@ class SageConnector: ) # ======================================== - # ÉTAPE 5 : COPIER UNITE + FAMILLE (OBLIGATOIRES !) + # ÉTAPE 5 : COPIER UNITÉ + FAMILLE # ======================================== logger.info("[OBJETS] Copie Unite + Famille depuis modèle...") - # Unite (obligatoire) + # Unite unite_trouvee = False - try: unite_obj = getattr(article_modele, "Unite", None) if unite_obj: @@ -7542,7 +7609,7 @@ class SageConnector: "Impossible de copier l'unité de vente depuis le modèle" ) - # 🔑 Famille (OBLIGATOIRE) - VERSION OPTIMISÉE SQL + SCANNER + # Famille famille_trouvee = False famille_code_personnalise = article_data.get("famille") famille_obj = None @@ -7553,12 +7620,9 @@ class SageConnector: ) try: - # ======================================== - # 🚀 ÉTAPE 1 : VÉRIFIER EXISTENCE VIA SQL (ULTRA-RAPIDE) - # ======================================== + # Vérifier existence via SQL famille_existe_sql = False famille_code_exact = None - famille_type = None try: with self._get_sql_connection() as conn: @@ -7579,41 +7643,23 @@ class SageConnector: famille_code_exact = self._safe_strip( row.FA_CodeFamille ) - famille_type = ( - row.FA_Type if len(row) > 1 else 0 - ) famille_existe_sql = True - - # ✅ VÉRIFIER LE TYPE - if famille_type == 1: - raise ValueError( - f"La famille '{famille_code_personnalise}' est de type 'Total' " - f"(agrégation comptable) et ne peut pas contenir d'articles.\n\n" - f"Utilisez plutôt une sous-famille de détail." - ) - logger.info( - f" [SQL] Famille trouvée : {famille_code_exact} (type={famille_type})" + f" [SQL] Famille trouvée : {famille_code_exact}" ) else: - logger.warning( - f" [SQL] Famille '{famille_code_personnalise}' introuvable" + raise ValueError( + f"Famille '{famille_code_personnalise}' introuvable" ) + except ValueError: + raise except Exception as e_sql: logger.warning(f" [SQL] Erreur : {e_sql}") - # ======================================== - # 🚀 ÉTAPE 2 : SI EXISTE EN SQL, CHARGER VIA COM (SCANNER) - # ======================================== + # Charger via COM if famille_existe_sql and famille_code_exact: - logger.info( - f" [COM] Recherche de '{famille_code_exact}' via scanner..." - ) - factory_famille = self.cial.FactoryFamille - - # ✅ Scanner List() (compatible Sage v12 - IBOFamilleFactory2) try: index = 1 max_scan = 1000 @@ -7624,13 +7670,11 @@ class SageConnector: if persist_test is None: break - # Cast et lecture fam_test = win32com.client.CastTo( persist_test, "IBOFamille3" ) fam_test.Read() - # Comparer les codes code_test = ( getattr(fam_test, "FA_CodeFamille", "") .strip() @@ -7638,7 +7682,6 @@ class SageConnector: ) if code_test == famille_code_exact.upper(): - # ✅ TROUVÉ ! famille_obj = fam_test famille_trouvee = True logger.info( @@ -7649,127 +7692,33 @@ class SageConnector: index += 1 except Exception as e: - if "Accès refusé" in str( - e - ) or "Access" in str(e): + if "Accès refusé" in str(e): break index += 1 - if not famille_trouvee: - logger.warning( - f" [COM] Famille '{famille_code_exact}' non trouvée après scan de {index-1} familles" + 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] Scanner échoué : {str(e)[:200]}" - ) - - # ✅ ASSIGNER LA FAMILLE SI TROUVÉE - if famille_obj: - # ✅ CRITIQUE : Re-lire juste avant assignation - famille_obj.Read() - article.Famille = famille_obj - logger.info( - f" [OK] Famille '{famille_code_personnalise}' assignée" - ) - else: - # ❌ FAMILLE INTROUVABLE VIA COM - logger.error( - f" [ERREUR] Famille '{famille_code_personnalise}' trouvée en SQL mais inaccessible via COM" - ) - - # Lister les familles disponibles - familles_disponibles = [] - try: - with self._get_sql_connection() as conn: - cursor = conn.cursor() - - cursor.execute( - """ - SELECT TOP 30 FA_CodeFamille, FA_Intitule - FROM F_FAMILLE - WHERE FA_Type = 0 - ORDER BY FA_CodeFamille - """ - ) - - rows = cursor.fetchall() - - for row in rows: - code = self._safe_strip( - row.FA_CodeFamille - ) - intitule = self._safe_strip( - row.FA_Intitule - ) - if code: - familles_disponibles.append( - f"{code} - {intitule}" - ) - except: - pass - - msg_erreur = f"Famille '{famille_code_personnalise}' trouvée en SQL mais inaccessible via COM." - - if familles_disponibles: - msg_erreur += ( - f"\n\nFamilles de DÉTAIL disponibles :\n" - + "\n".join(familles_disponibles) - ) - - msg_erreur += "\n\nSolution : Essayez avec ZDIVERS ou créez une nouvelle famille" - - raise ValueError(msg_erreur) - - else: - # Famille pas trouvée en SQL - familles_disponibles = [] - try: - with self._get_sql_connection() as conn: - cursor = conn.cursor() - - cursor.execute( - """ - SELECT TOP 30 FA_CodeFamille, FA_Intitule - FROM F_FAMILLE - WHERE FA_Type = 0 - ORDER BY FA_CodeFamille - """ - ) - - rows = cursor.fetchall() - - for row in rows: - code = self._safe_strip(row.FA_CodeFamille) - intitule = self._safe_strip(row.FA_Intitule) - if code: - familles_disponibles.append( - f"{code} - {intitule}" - ) - except: - pass - - msg_erreur = f"Famille '{famille_code_personnalise}' introuvable dans Sage." - - if familles_disponibles: - msg_erreur += ( - f"\n\nFamilles disponibles :\n" - + "\n".join(familles_disponibles) - ) - else: - msg_erreur += "\n\nAucune famille trouvée. Créez d'abord des familles dans Sage." - - raise ValueError(msg_erreur) + logger.warning(f" [COM] Erreur scanner : {e}") + raise except ValueError: - raise # Re-raise si c'est notre erreur de validation + raise except Exception as e: logger.warning( f" [WARN] Famille personnalisée non chargée : {str(e)[:100]}" ) - # Si pas de famille perso OU si échec, copier depuis le modèle + # Si pas de famille perso, copier depuis le modèle if not famille_trouvee: try: famille_obj = getattr(article_modele, "Famille", None) @@ -7782,12 +7731,8 @@ class SageConnector: except Exception as e: logger.debug(f" Famille non copiable : {str(e)[:80]}") - if not famille_trouvee: - logger.warning( - " ⚠️ Aucune famille assignée - risque d'erreur cohérence" - ) # ======================================== - # ÉTAPE 6 : CHAMPS OBLIGATOIRES (ORDRE CRITIQUE) + # ÉTAPE 6 : CHAMPS OBLIGATOIRES # ======================================== logger.info("[CHAMPS] Copie champs obligatoires depuis modèle...") @@ -7800,48 +7745,6 @@ class SageConnector: article.AR_SuiviStock = 2 logger.info(f" [OK] AR_SuiviStock=2 (FIFO/LIFO)") - # Champs standards - article.AR_Coef = float(getattr(article_modele, "AR_Coef", 2.0)) - article.AR_Garantie = int( - getattr(article_modele, "AR_Garantie", 12) - ) - article.AR_UnitePoids = int( - getattr(article_modele, "AR_UnitePoids", 3) - ) - article.AR_Sommeil = False - - # Champs de gestion - article.AR_Cycle = int(getattr(article_modele, "AR_Cycle", 1)) - article.AR_Delai = int(getattr(article_modele, "AR_Delai", 0)) - article.AR_DelaiFabrication = int( - getattr(article_modele, "AR_DelaiFabrication", 0) - ) - article.AR_Criticite = int( - getattr(article_modele, "AR_Criticite", 0) - ) - - # Options booléennes - article.AR_HorsStat = bool( - getattr(article_modele, "AR_HorsStat", False) - ) - article.AR_Escompte = bool( - getattr(article_modele, "AR_Escompte", False) - ) - article.AR_PrixTTC = bool( - getattr(article_modele, "AR_PrixTTC", False) - ) - article.AR_VteDebit = bool( - getattr(article_modele, "AR_VteDebit", False) - ) - article.AR_NotImp = bool( - getattr(article_modele, "AR_NotImp", False) - ) - article.AR_Exclure = bool( - getattr(article_modele, "AR_Exclure", False) - ) - - logger.info(f" [OK] Tous les champs obligatoires copiés") - # ======================================== # ÉTAPE 7 : PRIX # ======================================== @@ -7856,7 +7759,6 @@ class SageConnector: prix_achat = article_data.get("prix_achat") if prix_achat is not None: try: - # ⚠️ CORRECTION : Tester les deux noms possibles try: article.AR_PrixAch = float(prix_achat) logger.info( @@ -7871,7 +7773,7 @@ class SageConnector: logger.warning(f" Prix achat erreur : {str(e)[:100]}") # ======================================== - # ÉTAPE 8 : CODE EAN (Code-barres principal) + # ÉTAPE 8 : CODE EAN # ======================================== code_ean = article_data.get("code_ean") if code_ean: @@ -7890,28 +7792,7 @@ class SageConnector: pass # ======================================== - # ÉTAPE 10 : STOCK MINI/MAXI (NIVEAU ARTICLE) - # ======================================== - stock_mini = article_data.get("stock_mini") - stock_maxi = article_data.get("stock_maxi") - - # ✅ NOUVEAU : Définir au niveau ARTICLE (global) - if stock_mini is not None: - try: - article.AR_StockMini = float(stock_mini) - logger.info(f" AR_StockMini (article) : {stock_mini}") - except Exception as e: - logger.warning(f" AR_StockMini erreur : {e}") - - if stock_maxi is not None: - try: - article.AR_StockMaxi = float(stock_maxi) - logger.info(f" AR_StockMaxi (article) : {stock_maxi}") - except Exception as e: - logger.warning(f" AR_StockMaxi erreur : {e}") - - # ======================================== - # ÉTAPE 11 : ÉCRITURE ARTICLE + # ÉTAPE 10 : ÉCRITURE ARTICLE # ======================================== logger.info("[ARTICLE] Écriture dans Sage...") @@ -7932,30 +7813,33 @@ class SageConnector: raise RuntimeError(f"Échec création article : {error_detail}") # ======================================== - # ÉTAPE 12 : DÉFINIR LE STOCK (NIVEAU DÉPÔT) + # ÉTAPE 11 : DÉFINIR LE STOCK DANS F_ARTSTOCK (CRITIQUE) # ======================================== - stock_reel = article_data.get("stock_reel") stock_defini = False stock_erreur = None - if stock_reel and stock_reel > 0: + # 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 : {stock_reel} unités sur dépôt '{depot_a_utiliser['code']}'" + f"[STOCK] Définition stock dans F_ARTSTOCK (dépôt '{depot_a_utiliser['code']}')..." ) try: depot_obj = depot_a_utiliser["objet"] - factory_depot_stock = None + # Chercher FactoryArticleStock ou FactoryDepotStock + factory_stock = None for factory_name in [ - "FactoryDepotStock", "FactoryArticleStock", + "FactoryDepotStock", ]: try: - factory_depot_stock = getattr( + factory_stock = getattr( depot_obj, factory_name, None ) - if factory_depot_stock: + if factory_stock: logger.info( f" Factory trouvée : {factory_name}" ) @@ -7963,12 +7847,13 @@ class SageConnector: except: continue - if not factory_depot_stock: + if not factory_stock: raise RuntimeError( - "FactoryDepotStock introuvable sur le dépôt" + "Factory de stock introuvable sur le dépôt" ) - stock_persist = factory_depot_stock.Create() + # Créer l'entrée de stock dans F_ARTSTOCK + stock_persist = factory_stock.Create() stock_obj = win32com.client.CastTo( stock_persist, "IBODepotStock3" ) @@ -7978,96 +7863,42 @@ class SageConnector: stock_obj.AR_Ref = reference # Stock réel - stock_obj.AS_QteSto = float(stock_reel) - logger.info(f" AS_QteSto = {stock_reel}") + if stock_reel: + stock_obj.AS_QteSto = float(stock_reel) + logger.info(f" AS_QteSto = {stock_reel}") - # ✅ Stock minimum (niveau dépôt) - if stock_mini is not None: + # Stock minimum + if stock_mini: try: - stock_obj.AS_QteMin = float(stock_mini) - logger.info(f" AS_QteMin (dépôt) = {stock_mini}") + stock_obj.AS_QteMini = float(stock_mini) + logger.info(f" AS_QteMini = {stock_mini}") except Exception as e: - logger.warning(f" AS_QteMin non défini : {e}") + logger.warning(f" AS_QteMini non défini : {e}") - # ✅ Stock maximum (niveau dépôt) - if stock_maxi is not None: + # Stock maximum + if stock_maxi: try: - stock_obj.AS_QteMax = float(stock_maxi) - logger.info(f" AS_QteMax (dépôt) = {stock_maxi}") + stock_obj.AS_QteMaxi = float(stock_maxi) + logger.info(f" AS_QteMaxi = {stock_maxi}") except Exception as e: - logger.warning(f" AS_QteMax non défini : {e}") + logger.warning(f" AS_QteMaxi non défini : {e}") stock_obj.Write() stock_defini = True logger.info( - f" [OK] Stock défini : {stock_reel} unités (min={stock_mini}, max={stock_maxi})" + 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 : {e}", exc_info=True + f" [ERREUR] Stock non défini dans F_ARTSTOCK : {e}", + exc_info=True, ) - # Gérer stock_mini/maxi SANS stock_reel - elif stock_mini is not None or stock_maxi is not None: - logger.info( - f"[STOCK] Définition stock_mini/maxi sans stock_reel..." - ) - - try: - depot_obj = depot_a_utiliser["objet"] - - factory_depot_stock = None - for factory_name in [ - "FactoryDepotStock", - "FactoryArticleStock", - ]: - try: - factory_depot_stock = getattr( - depot_obj, factory_name, None - ) - if factory_depot_stock: - break - except: - pass - - if factory_depot_stock: - stock_persist = factory_depot_stock.Create() - stock_obj = win32com.client.CastTo( - stock_persist, "IBODepotStock3" - ) - stock_obj.SetDefault() - - stock_obj.AR_Ref = reference - stock_obj.AS_QteSto = 0.0 # Stock réel à 0 - - if stock_mini is not None: - try: - stock_obj.AS_QteMin = float(stock_mini) - logger.info(f" AS_QteMin = {stock_mini}") - except: - pass - - if stock_maxi is not None: - try: - stock_obj.AS_QteMax = float(stock_maxi) - logger.info(f" AS_QteMax = {stock_maxi}") - except: - pass - - stock_obj.Write() - stock_defini = True - logger.info( - f" [OK] Stock min/max défini sans stock réel" - ) - - except Exception as e: - logger.warning(f" [WARN] Stock min/max non défini : {e}") - # ======================================== - # ÉTAPE 13 : COMMIT (CRITIQUE POUR PERSISTANCE) + # ÉTAPE 12 : COMMIT # ======================================== if transaction_active: try: @@ -8079,7 +7910,7 @@ class SageConnector: logger.warning(f"[COMMIT] Erreur commit : {e}") # ======================================== - # ÉTAPE 14 : VÉRIFICATION & RELECTURE + # ÉTAPE 13 : VÉRIFICATION & RELECTURE # ======================================== logger.info("[VERIF] Relecture article créé...") @@ -8095,71 +7926,71 @@ class SageConnector: article_cree.Read() # ======================================== - # ÉTAPE 15 : LIRE LES STOCKS PAR DÉPÔT + # ÉTAPE 14 : VÉRIFIER LE STOCK DANS F_ARTSTOCK VIA SQL # ======================================== stocks_par_depot = [] stock_total = 0.0 - for depot_info in depots_disponibles: - try: - depot_obj = depot_info["objet"] - factory_depot_stock = getattr( - depot_obj, "FactoryDepotStock", None + 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(),), ) - if factory_depot_stock: - index = 1 - while index <= 1000: - try: - stock_persist = factory_depot_stock.List(index) - if stock_persist is None: - break + depot_rows = cursor.fetchall() - stock = win32com.client.CastTo( - stock_persist, "IBODepotStock3" - ) - stock.Read() + 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 - article_ref_stock = getattr( - stock, "AR_Ref", "" - ).strip() - if article_ref_stock == reference: - qte = float( - getattr(stock, "AS_QteSto", 0.0) - ) - stock_total += qte + stocks_par_depot.append( + { + "depot_code": 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 + ), + } + ) - stocks_par_depot.append( - { - "depot_code": depot_info["code"], - "depot_intitule": depot_info[ - "intitule" - ], - "quantite": qte, - "qte_mini": float( - getattr(stock, "AS_QteMin", 0.0) - ), - "qte_maxi": float( - getattr(stock, "AS_QteMax", 0.0) - ), - } - ) - break + logger.info( + f"[VERIF] Stock dans F_ARTSTOCK : {stock_total} unités dans {len(stocks_par_depot)} dépôt(s)" + ) - index += 1 - except Exception as e: - if "Acces refuse" in str(e): - break - index += 1 - except: - pass + 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 16 : EXTRACTION COMPLÈTE + ENRICHISSEMENT + # ÉTAPE 15 : EXTRACTION COMPLÈTE # ======================================== logger.info("[EXTRACTION] Extraction complète de l'article créé...") @@ -8167,32 +7998,22 @@ class SageConnector: resultat = self._extraire_article(article_cree) if not resultat: - # Fallback si _extraire_article échoue + # Fallback si extraction échoue resultat = { "reference": reference, "designation": designation, } # ======================================== - # ENRICHIR AVEC LES VALEURS QU'ON A DÉFINIES + # ÉTAPE 16 : FORCER LES VALEURS DE STOCK DEPUIS F_ARTSTOCK # ======================================== - # ⚠️ IMPORTANT : Certaines valeurs ne sont pas encore relues correctement - # juste après le Write(), donc on force les valeurs qu'on a définies - - # ✅ 1. PRIX (forcer les valeurs définies) - if prix_vente is not None: - resultat["prix_vente"] = float(prix_vente) - - if prix_achat is not None: - resultat["prix_achat"] = float(prix_achat) - - # ✅ 2. STOCK (utiliser le calcul depuis les dépôts, plus fiable) + # ✅ 1. STOCK (forcer les valeurs depuis F_ARTSTOCK) resultat["stock_reel"] = stock_total - if stock_mini is not None: + if stock_mini: resultat["stock_mini"] = float(stock_mini) - if stock_maxi is not None: + if stock_maxi: resultat["stock_maxi"] = float(stock_maxi) # Stock disponible = stock réel (article neuf, pas de réservation) @@ -8200,21 +8021,25 @@ class SageConnector: resultat["stock_reserve"] = 0.0 resultat["stock_commande"] = 0.0 - # ✅ 3. DESCRIPTION (forcer la valeur définie) + # ✅ 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 (forcer la valeur définie) + # ✅ 4. CODE EAN if code_ean: resultat["code_ean"] = str(code_ean) - resultat["code_barre"] = str( - code_ean - ) # Identique (code-barres principal) + resultat["code_barre"] = str(code_ean) - # ✅ 5. FAMILLE (si personnalisée, forcer le code et libellé) + # ✅ 5. FAMILLE if famille_code_personnalise and famille_trouvee: resultat["famille_code"] = famille_code_personnalise - # Essayer de récupérer le libellé try: if famille_obj: famille_obj.Read() @@ -8224,27 +8049,21 @@ class SageConnector: except: pass - # ✅ 6. DATES (forcer date actuelle pour cohérence) - from datetime import datetime + # ✅ 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"], + } - date_maintenant = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - resultat["date_creation"] = date_maintenant - resultat["date_modification"] = date_maintenant - - # ✅ 7. INFOS DÉPÔTS - resultat["stocks_par_depot"] = stocks_par_depot - resultat["depot_principal"] = { - "code": depot_a_utiliser["code"], - "intitule": depot_a_utiliser["intitule"], - } - - # ✅ 8. SUIVI DE STOCK + # ✅ 7. SUIVI DE STOCK resultat["suivi_stock_active"] = stock_defini - # ✅ 9. AVERTISSEMENT SI STOCK NON DÉFINI - if stock_reel and not 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é ({stock_reel}) mais non défini : {stock_erreur}" + f"Stock demandé mais non défini dans F_ARTSTOCK : {stock_erreur}" ) logger.info( @@ -9294,21 +9113,26 @@ class SageConnector: def creer_entree_stock(self, entree_data: Dict) -> Dict: try: with self._com_context(), self._lock_com: - logger.info(f"[STOCK] === CRÉATION ENTRÉE STOCK ===") - logger.info(f"[STOCK] {len(entree_data.get('lignes', []))} ligne(s)") + 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: - factory = self.cial.FactoryDocumentStock - persist = factory.CreateType(180) # 180 = Entrée - doc = win32com.client.CastTo(persist, "IBODocumentStock3") + # ======================================== + # ÉTAPE 1 : CRÉER LE DOCUMENT D'ENTRÉE + # ======================================== + factory_doc = self.cial.FactoryDocumentStock + persist_doc = factory_doc.CreateType(180) # 180 = Entrée + doc = win32com.client.CastTo(persist_doc, "IBODocumentStock3") doc.SetDefault() - # Date import pywintypes date_mouv = entree_data.get("date_mouvement") @@ -9319,36 +9143,52 @@ class SageConnector: else: doc.DO_Date = pywintypes.Time(datetime.now()) - # Référence if entree_data.get("reference"): doc.DO_Ref = entree_data["reference"] doc.Write() - logger.info( - f"[STOCK] Document créé : {getattr(doc, 'DO_Piece', '?')}" - ) + 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 - logger.info(f"[STOCK] Factory lignes : FactoryDocumentLigne") except: factory_lignes = doc.FactoryDocumentStockLigne - logger.info( - f"[STOCK] Factory lignes : FactoryDocumentStockLigne" - ) - - factory_article = self.cial.FactoryArticle - stocks_mis_a_jour = [] 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} ========" - ) + logger.info(f"[STOCK] Ligne {idx}: {article_ref} x {quantite}") - # Charger l'article + # A. CHARGER L'ARTICLE persist_article = factory_article.ReadReference(article_ref) if not persist_article: raise ValueError(f"Article {article_ref} introuvable") @@ -9358,221 +9198,492 @@ class SageConnector: ) article_obj.Read() - ar_suivi = getattr(article_obj, "AR_SuiviStock", 0) - ar_design = getattr(article_obj, "AR_Design", article_ref) - - logger.info(f"[STOCK] Article : {ar_design}") - logger.info( - f"[STOCK] AR_SuiviStock : {ar_suivi} ({'CMUP' if ar_suivi == 1 else 'FIFO/LIFO' if ar_suivi == 2 else 'Aucun'})" - ) - - # Gérer le lot selon le mode de suivi - numero_lot = ligne_data.get("numero_lot") - - if ar_suivi == 1: # CMUP - if numero_lot: - logger.warning( - f"[STOCK] CMUP : Suppression du lot '{numero_lot}'" - ) - numero_lot = None - - elif ar_suivi == 2: # FIFO/LIFO - if not numero_lot: - import uuid - - numero_lot = f"AUTO-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6].upper()}" - logger.info( - f"[STOCK] FIFO/LIFO : Lot auto-généré '{numero_lot}'" - ) - - # ======================================== - # CRÉER LA LIGNE - # ======================================== + # B. CRÉER LA LIGNE DE MOUVEMENT ligne_persist = factory_lignes.Create() - - # Cast selon le type disponible try: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentLigne3" ) - logger.info(f"[STOCK] Cast : IBODocumentLigne3") except: ligne_obj = win32com.client.CastTo( ligne_persist, "IBODocumentStockLigne3" ) - logger.info(f"[STOCK] Cast : IBODocumentStockLigne3") ligne_obj.SetDefault() - # ======================================== - # LIAISON ARTICLE + QUANTITÉ - # ======================================== - article_lie = False - methode_utilisee = None - - # MÉTHODE 1 : SetDefaultArticleReference() + # Lier l'article au mouvement try: - logger.info( - f"[STOCK] SetDefaultArticleReference('{article_ref}', {quantite})..." - ) ligne_obj.SetDefaultArticleReference( article_ref, float(quantite) ) - article_lie = True - methode_utilisee = "SetDefaultArticleReference" - logger.info( - f"[STOCK] ✅ Article lié via SetDefaultArticleReference()" - ) - except Exception as e1: - logger.warning( - f"[STOCK] SetDefaultArticleReference échoué : {str(e1)[:150]}" - ) - - # MÉTHODE 2 : SetDefaultArticle() - if not article_lie: + except: try: - logger.info( - f"[STOCK] SetDefaultArticle(article_obj, {quantite})..." - ) ligne_obj.SetDefaultArticle( article_obj, float(quantite) ) - article_lie = True - methode_utilisee = "SetDefaultArticle" - logger.info( - f"[STOCK] ✅ Article lié via SetDefaultArticle()" - ) - except Exception as e2: - logger.warning( - f"[STOCK] SetDefaultArticle échoué : {str(e2)[:150]}" + except: + raise ValueError( + f"Impossible de lier l'article {article_ref}" ) - if not article_lie: - raise ValueError( - f"Impossible de lier l'article {article_ref}" - ) - - # ======================================== - # DÉFINIR LE LOT (si FIFO/LIFO) - # ======================================== - if numero_lot and ar_suivi == 2: - logger.info(f"[STOCK] Définition du lot '{numero_lot}'...") - - try: - # MÉTHODE 1 : SetDefaultLot() - ligne_obj.SetDefaultLot(numero_lot) - logger.info( - f"[STOCK] ✅ Lot défini via SetDefaultLot()" - ) - except Exception as e_lot1: - logger.warning( - f"[STOCK] SetDefaultLot échoué : {str(e_lot1)[:150]}" - ) - - # MÉTHODE 2 : Attribut LS_NoSerie - try: - ligne_obj.LS_NoSerie = numero_lot - logger.info(f"[STOCK] ✅ Lot défini via LS_NoSerie") - except Exception as e_lot2: - logger.warning( - f"[STOCK] LS_NoSerie échoué : {str(e_lot2)[:150]}" - ) - - # ======================================== - # PRIX UNITAIRE - # ======================================== + # Prix prix = ligne_data.get("prix_unitaire") if prix: try: ligne_obj.DL_PrixUnitaire = float(prix) - logger.info(f"[STOCK] Prix unitaire : {prix}") except: pass - # ======================================== - # ÉCRIRE LA LIGNE - # ======================================== - logger.info(f"[STOCK] Appel Write()...") + # Écrire la ligne ligne_obj.Write() - logger.info(f"[STOCK] ✅ Write() réussi") # ======================================== - # VÉRIFICATION (via Article.AR_Ref, pas AR_Ref direct) + # ÉTAPE 4 : GÉRER LES STOCKS MINI/MAXI (COM PUR) # ======================================== - logger.info(f"[STOCK] Vérification...") - ligne_obj.Read() + if stock_mini is not None or stock_maxi is not None: + logger.info( + f"[STOCK] Ajustement stock pour {article_ref}..." + ) - ref_verifiee = None + try: + # MÉTHODE A : Via Article.FactoryArticleStock (LA BONNE MÉTHODE) + logger.info( + f" [COM] Méthode A : Article.FactoryArticleStock" + ) - # Vérifier via l'objet Article (objet COM) - try: - article_lie_obj = getattr(ligne_obj, "Article", None) - if article_lie_obj: - article_lie_obj.Read() - ref_verifiee = getattr( - article_lie_obj, "AR_Ref", "" - ).strip() - if ref_verifiee: - logger.info( - f"[STOCK] ✅ Référence vérifiée via Article.AR_Ref : {ref_verifiee}" + # 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" ) - except Exception as e_verif: - logger.warning( - f"[STOCK] Impossible de vérifier via Article : {e_verif}" - ) - # Si pas de vérification possible, considérer comme OK si Write() a réussi - if not ref_verifiee: - logger.warning( - f"[STOCK] ⚠️ Impossible de vérifier la référence, mais Write() a réussi" - ) - ref_verifiee = article_ref # Supposer que c'est OK + if factory_article_stock: + # 3. Chercher si le stock existe déjà + stock_trouve = None + index_stock = 1 - logger.info(f"[STOCK] ✅ LIGNE {idx} COMPLÈTE") + 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, - "methode_liaison": methode_utilisee, - "reference_verifiee": ref_verifiee, - "numero_lot": numero_lot if ar_suivi == 2 else None, + "stock_mini_defini": stock_mini, + "stock_maxi_defini": stock_maxi, } ) # ======================================== - # FINALISER LE DOCUMENT + # ÉTAPE 5 : FINALISER LE DOCUMENT # ======================================== - logger.info(f"[STOCK] Write() document final...") doc.Write() doc.Read() numero = getattr(doc, "DO_Piece", "") - logger.info(f"[STOCK] ✅ Document finalisé : {numero}") + 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 - try: - self.cial.CptaApplication.CommitTrans() - logger.info(f"[STOCK] ✅ Transaction committée") - except: - logger.info(f"[STOCK] ✅ Changements sauvegardés") + 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": 0, + "type": 180, + "type_libelle": "Entrée en stock", "date": str(getattr(doc, "DO_Date", "")), "nb_lignes": len(stocks_mis_a_jour), - "reference": entree_data.get("reference"), "stocks_mis_a_jour": stocks_mis_a_jour, } except Exception as e: + 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) - try: - self.cial.CptaApplication.RollbackTrans() - logger.info(f"[STOCK] Rollback effectué") - except: - pass raise ValueError(f"Erreur création entrée stock : {str(e)}") except Exception as e: @@ -9740,14 +9851,14 @@ class SageConnector: # Qte mini/maxi try: qte_mini = float( - getattr(stock, "AS_QteMin", 0.0) + getattr(stock, "AS_QteMini", 0.0) ) except: pass try: qte_maxi = float( - getattr(stock, "AS_QteMax", 0.0) + getattr(stock, "AS_QteMaxi", 0.0) ) except: pass @@ -10069,12 +10180,12 @@ class SageConnector: "quantite": qte, "qte_mini": float( getattr( - stock_item, "AS_QteMin", 0.0 + stock_item, "AS_QteMini", 0.0 ) ), "qte_maxi": float( getattr( - stock_item, "AS_QteMax", 0.0 + stock_item, "AS_QteMaxi", 0.0 ) ), } @@ -10154,14 +10265,14 @@ class SageConnector: "qte_mini": float( getattr( stock, - "AS_QteMin", + "AS_QteMini", 0.0, ) ), "qte_maxi": float( getattr( stock, - "AS_QteMax", + "AS_QteMaxi", 0.0, ) ),