diff --git a/api.py b/api.py index 99a1b2f..dd7bd98 100644 --- a/api.py +++ b/api.py @@ -89,11 +89,12 @@ class TypeDocument(int, Enum): BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR FACTURE = settings.SAGE_TYPE_FACTURE + class TypeDocumentSQL(int, Enum): DEVIS = settings.SAGE_TYPE_DEVIS BON_COMMANDE = 1 PREPARATION = 2 - BON_LIVRAISON =3 + BON_LIVRAISON = 3 BON_RETOUR = 4 BON_AVOIR = 5 FACTURE = 6 @@ -121,6 +122,7 @@ class StatutEmail(str, Enum): # ===================================================== class ClientResponse(BaseModel): """Modèle de réponse client simplifié (pour listes)""" + numero: Optional[str] = None intitule: Optional[str] = None adresse: Optional[str] = None @@ -132,53 +134,60 @@ class ClientResponse(BaseModel): class ClientDetails(BaseModel): """Modèle de réponse client complet (pour GET /clients/{code})""" - + # === IDENTIFICATION === numero: Optional[str] = Field(None, description="Code client (CT_Num)") - intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") - + intitule: Optional[str] = Field( + None, description="Raison sociale ou Nom complet (CT_Intitule)" + ) + # === TYPE DE TIERS === type_tiers: Optional[str] = Field( - None, - description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'" + None, + description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'", ) qualite: Optional[str] = Field( - None, - description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)" + None, + description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)", ) - est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") - est_fournisseur: Optional[bool] = Field(None, description="True si fournisseur (CT_Qualite=2 ou 3)") - + est_prospect: Optional[bool] = Field( + None, description="True si prospect (CT_Prospect=1)" + ) + est_fournisseur: Optional[bool] = Field( + None, description="True si fournisseur (CT_Qualite=2 ou 3)" + ) + # === TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === forme_juridique: Optional[str] = Field( - None, - description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier" + None, + description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier", ) est_entreprise: Optional[bool] = Field( - None, - description="True si entreprise (forme_juridique renseignée)" + None, description="True si entreprise (forme_juridique renseignée)" ) est_particulier: Optional[bool] = Field( - None, - description="True si particulier (pas de forme juridique)" + None, description="True si particulier (pas de forme juridique)" ) - + # === STATUT === est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") - est_en_sommeil: Optional[bool] = Field(None, description="True si en sommeil (CT_Sommeil=1)") - + est_en_sommeil: Optional[bool] = Field( + None, description="True si en sommeil (CT_Sommeil=1)" + ) + # === IDENTITÉ (POUR PARTICULIERS) === civilite: Optional[str] = Field(None, description="M., Mme, Mlle (CT_Civilite)") nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)") prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)") nom_complet: Optional[str] = Field( - None, - description="Nom complet formaté : 'Civilité Prénom Nom'" + None, description="Nom complet formaté : 'Civilité Prénom Nom'" ) - + # === CONTACT === - contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") - + contact: Optional[str] = Field( + None, description="Nom du contact principal (CT_Contact)" + ) + # === ADRESSE === adresse: Optional[str] = Field(None, description="Adresse ligne 1") complement: Optional[str] = Field(None, description="Complément d'adresse") @@ -186,40 +195,52 @@ class ClientDetails(BaseModel): ville: Optional[str] = Field(None, description="Ville") region: Optional[str] = Field(None, description="Région/État") pays: Optional[str] = Field(None, description="Pays") - + # === TÉLÉCOMMUNICATIONS === telephone: Optional[str] = Field(None, description="Téléphone fixe") portable: Optional[str] = Field(None, description="Téléphone mobile") telecopie: Optional[str] = Field(None, description="Fax") email: Optional[str] = Field(None, description="Email principal") site_web: Optional[str] = Field(None, description="Site web") - + # === INFORMATIONS JURIDIQUES (ENTREPRISES) === siret: Optional[str] = Field(None, description="N° SIRET (14 chiffres)") siren: Optional[str] = Field(None, description="N° SIREN (9 chiffres)") tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire") code_naf: Optional[str] = Field(None, description="Code NAF/APE") - + # === INFORMATIONS COMMERCIALES === secteur: Optional[str] = Field(None, description="Secteur d'activité") effectif: Optional[int] = Field(None, description="Nombre d'employés") ca_annuel: Optional[float] = Field(None, description="Chiffre d'affaires annuel") - commercial_code: Optional[str] = Field(None, description="Code du commercial rattaché") + commercial_code: Optional[str] = Field( + None, description="Code du commercial rattaché" + ) commercial_nom: Optional[str] = Field(None, description="Nom du commercial") - + # === CATÉGORIES === - categorie_tarifaire: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") - categorie_comptable: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") - + categorie_tarifaire: Optional[int] = Field( + None, description="Catégorie tarifaire (N_CatTarif)" + ) + categorie_comptable: Optional[int] = Field( + None, description="Catégorie comptable (N_CatCompta)" + ) + # === INFORMATIONS FINANCIÈRES === - encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé") - assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit") + encours_autorise: Optional[float] = Field( + None, description="Encours maximum autorisé" + ) + assurance_credit: Optional[float] = Field( + None, description="Montant assurance crédit" + ) compte_general: Optional[str] = Field(None, description="Compte général principal") - + # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field(None, description="Date de dernière modification") - + date_modification: Optional[str] = Field( + None, description="Date de dernière modification" + ) + class Config: json_schema_extra = { "example": { @@ -236,70 +257,90 @@ class ClientDetails(BaseModel): "portable": "0612345678", "email": "contact@exemple.fr", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } + class ArticleResponse(BaseModel): """ Modèle de réponse pour un article Sage - + ✅ ENRICHI avec tous les champs disponibles """ + # === IDENTIFICATION === reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") - designation_complementaire: Optional[str] = Field(None, description="Désignation complémentaire") - + designation_complementaire: Optional[str] = Field( + None, description="Désignation complémentaire" + ) + # === CODE EAN / CODE-BARRES === code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") code_barre: Optional[str] = Field(None, description="Code-barres (alias)") - + # === PRIX === prix_vente: float = Field(..., description="Prix de vente HT") prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") prix_revient: Optional[float] = Field(None, description="Prix de revient") - + # === STOCK === stock_reel: float = Field(..., description="Stock réel") stock_mini: Optional[float] = Field(None, description="Stock minimum") stock_maxi: Optional[float] = Field(None, description="Stock maximum") - stock_reserve: Optional[float] = Field(None, description="Stock réservé (en commande)") - stock_commande: Optional[float] = Field(None, description="Stock en commande fournisseur") - stock_disponible: Optional[float] = Field(None, description="Stock disponible (réel - réservé)") - + stock_reserve: Optional[float] = Field( + None, description="Stock réservé (en commande)" + ) + stock_commande: Optional[float] = Field( + None, description="Stock en commande fournisseur" + ) + stock_disponible: Optional[float] = Field( + None, description="Stock disponible (réel - réservé)" + ) + # === DESCRIPTIONS === - description: Optional[str] = Field(None, description="Description détaillée / Commentaire") - + description: Optional[str] = Field( + None, description="Description détaillée / Commentaire" + ) + # === CLASSIFICATION === - type_article: Optional[int] = Field(None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)") + type_article: Optional[int] = Field( + None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)" + ) type_article_libelle: Optional[str] = Field(None, description="Libellé du type") famille_code: Optional[str] = Field(None, description="Code famille") famille_libelle: Optional[str] = Field(None, description="Libellé famille") - + # === FOURNISSEUR PRINCIPAL === - fournisseur_principal: Optional[str] = Field(None, description="Code fournisseur principal") - fournisseur_nom: Optional[str] = Field(None, description="Nom fournisseur principal") - + fournisseur_principal: Optional[str] = Field( + None, description="Code fournisseur principal" + ) + fournisseur_nom: Optional[str] = Field( + None, description="Nom fournisseur principal" + ) + # === UNITÉS === unite_vente: Optional[str] = Field(None, description="Unité de vente") unite_achat: Optional[str] = Field(None, description="Unité d'achat") - + # === CARACTÉRISTIQUES PHYSIQUES === poids: Optional[float] = Field(None, description="Poids (kg)") volume: Optional[float] = Field(None, description="Volume (m³)") - + # === STATUT === est_actif: bool = Field(True, description="Article actif") en_sommeil: bool = Field(False, description="Article en sommeil") - + # === TVA === tva_code: Optional[str] = Field(None, description="Code TVA") tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") - + # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") - date_modification: Optional[str] = Field(None, description="Date de dernière modification") + date_modification: Optional[str] = Field( + None, description="Date de dernière modification" + ) class LigneDevis(BaseModel): @@ -360,27 +401,35 @@ class BaremeRemiseResponse(BaseModel): class ClientCreateAPIRequest(BaseModel): """Modèle pour création d'un nouveau client""" - - intitule: str = Field(..., min_length=1, max_length=69, description="Raison sociale ou Nom complet") - compte_collectif: str = Field("411000", description="Compte comptable (411000 par défaut)") - num: Optional[str] = Field(None, max_length=17, description="Code client souhaité (auto si vide)") - + + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale ou Nom complet" + ) + compte_collectif: str = Field( + "411000", description="Compte comptable (411000 par défaut)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code client souhaité (auto si vide)" + ) + # Adresse adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) ville: Optional[str] = Field(None, max_length=35) pays: Optional[str] = Field(None, max_length=35) - + # Contact email: Optional[EmailStr] = None telephone: Optional[str] = Field(None, max_length=21, description="Téléphone fixe") portable: Optional[str] = Field(None, max_length=21, description="Téléphone mobile") - + # Juridique - forme_juridique: Optional[str] = Field(None, max_length=50, description="SARL, SA, SAS, EI, etc.") + forme_juridique: Optional[str] = Field( + None, max_length=50, description="SARL, SA, SAS, EI, etc." + ) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { @@ -393,13 +442,14 @@ class ClientCreateAPIRequest(BaseModel): "portable": "0612345678", "email": "contact@nouvelle-entreprise.fr", "siret": "12345678901234", - "tva_intra": "FR12345678901" + "tva_intra": "FR12345678901", } } + class ClientUpdateRequest(BaseModel): """Modèle pour modification d'un client existant""" - + intitule: Optional[str] = Field(None, min_length=1, max_length=69) adresse: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) @@ -411,13 +461,13 @@ class ClientUpdateRequest(BaseModel): forme_juridique: Optional[str] = Field(None, max_length=50) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) - + class Config: json_schema_extra = { "example": { "email": "nouveau@email.fr", "telephone": "0198765432", - "portable": "0687654321" + "portable": "0687654321", } } @@ -780,8 +830,10 @@ class FactureUpdateRequest(BaseModel): } } + class ArticleCreateRequest(BaseModel): """Schéma pour création d'article""" + reference: str = Field(..., max_length=18, description="Référence article") designation: str = Field(..., max_length=69, description="Désignation") famille: Optional[str] = Field(None, max_length=18, description="Code famille") @@ -797,18 +849,145 @@ class ArticleCreateRequest(BaseModel): class ArticleUpdateRequest(BaseModel): """Schéma pour modification d'article""" + designation: Optional[str] = Field(None, max_length=69) prix_vente: Optional[float] = Field(None, ge=0) prix_achat: Optional[float] = Field(None, ge=0) - stock_reel: Optional[float] = Field(None, ge=0, description="⚠️ Critique pour erreur 2881") + stock_reel: Optional[float] = Field( + None, ge=0, description="⚠️ Critique pour erreur 2881" + ) stock_mini: Optional[float] = Field(None, ge=0) code_ean: Optional[str] = Field(None, max_length=13) description: Optional[str] = Field(None) + + +class FamilleCreateRequest(BaseModel): + """Schéma pour création de famille d'articles""" + + code: str = Field(..., max_length=18, description="Code famille (max 18 car)") + intitule: str = Field(..., max_length=69, description="Intitulé (max 69 car)") + type: int = Field(0, ge=0, le=1, description="0=Détail, 1=Total") + compte_achat: Optional[str] = Field( + None, max_length=13, description="Compte général achat (ex: 607000)" + ) + compte_vente: Optional[str] = Field( + None, max_length=13, description="Compte général vente (ex: 707000)" + ) + + class Config: + json_schema_extra = { + "example": { + "code": "PRODLAIT", + "intitule": "Produits laitiers", + "type": 0, + "compte_achat": "607000", + "compte_vente": "707000", + } + } + + +class FamilleResponse(BaseModel): + """Modèle de réponse pour une famille d'articles""" + + code: str = Field(..., description="Code famille") + intitule: str = Field(..., description="Intitulé") + type: int = Field(..., description="Type (0=Détail, 1=Total)") + type_libelle: str = Field(..., description="Libellé du type") + est_total: bool = Field(..., description="True si type Total") + compte_achat: Optional[str] = Field(None, description="Compte général achat") + compte_vente: Optional[str] = Field(None, description="Compte général vente") + unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") + coef: Optional[float] = Field(None, description="Coefficient") + + class Config: + json_schema_extra = { + "example": { + "code": "ZDIVERS", + "intitule": "Frais et accessoires", + "type": 0, + "type_libelle": "Détail", + "est_total": False, + "compte_achat": "607000", + "compte_vente": "707000", + "unite_vente": "U", + "coef": 2.0, + } + } + +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')") + prix_unitaire: Optional[float] = Field(None, ge=0, description="Prix unitaire (optionnel)") + commentaire: Optional[str] = Field(None, description="Commentaire ligne") + + +class EntreeStockRequest(BaseModel): + """Création d'un bon d'entrée en stock""" + date_entree: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") + lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_entree": "2025-01-15", + "reference": "REC-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 50, + "depot_code": "01", + "prix_unitaire": 10.50, + "commentaire": "Réception fournisseur" + } + ], + "commentaire": "Réception livraison fournisseur XYZ" + } + } + + +class SortieStockRequest(BaseModel): + """Création d'un bon de sortie de stock""" + date_sortie: Optional[date] = Field(None, description="Date du mouvement (aujourd'hui par défaut)") + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field(None, description="Dépôt principal (si applicable)") + lignes: List[MouvementStockLigneRequest] = Field(..., min_items=1, description="Lignes du mouvement") + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_sortie": "2025-01-15", + "reference": "SOR-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 10, + "depot_code": "01", + "commentaire": "Utilisation interne" + } + ], + "commentaire": "Consommation atelier" + } + } + + +class MouvementStockResponse(BaseModel): + """Réponse pour un mouvement de stock""" + numero: str = Field(..., description="Numéro du mouvement") + type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") + type_libelle: str = Field(..., description="Libellé du type") + date: str = Field(..., description="Date du mouvement") + reference: Optional[str] = Field(None, description="Référence externe") + nb_lignes: int = Field(..., description="Nombre de lignes") -# ===================================================== -# SERVICES EXTERNES (Universign) -# ===================================================== async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str ) -> Dict: @@ -923,17 +1102,12 @@ async def universign_statut(transaction_id: str) -> Dict: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} - -# ===================================================== -# CYCLE DE VIE -# ===================================================== @asynccontextmanager async def lifespan(app: FastAPI): # Init base de données await init_db() logger.info("✅ Base de données initialisée") - # ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue email_queue.session_factory = async_session_factory email_queue.sage_client = sage_client @@ -973,12 +1147,8 @@ app.add_middleware( app.include_router(auth_router) -# ===================================================== -# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) -# ===================================================== @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): - """🔍 Recherche clients via gateway Windows""" try: clients = sage_client.lister_clients(filtre=query or "") return [ClientDetails(**c) for c in clients] @@ -989,15 +1159,6 @@ async def rechercher_clients(query: Optional[str] = Query(None)): @app.get("/clients/{code}", tags=["Clients"]) async def lire_client_detail(code: str): - """ - 📄 Lecture détaillée d'un client par son code - - Args: - code: Code du client (ex: "CLI000001", "SARL", etc.) - - Returns: - Toutes les informations du client - """ try: client = sage_client.lire_client(code) @@ -1019,23 +1180,6 @@ async def modifier_client( client_update: ClientUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un client existant - - Args: - code: Code du client à modifier - client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - - Returns: - Client modifié avec ses nouvelles valeurs - - Example: - PUT /clients/SARL - { - "email": "nouveau@email.fr", - "telephone": "0198765432" - } - """ try: # Appel à la gateway Windows resultat = sage_client.modifier_client( @@ -1064,9 +1208,6 @@ async def modifier_client( async def ajouter_client( client: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'un nouveau client dans Sage 100c - """ try: nouveau_client = sage_client.creer_client(client.dict()) @@ -1087,7 +1228,6 @@ async def ajouter_client( @app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): - """🔍 Recherche articles via gateway Windows""" try: articles = sage_client.lister_articles(filtre=query or "") return [ArticleResponse(**a) for a in articles] @@ -1095,229 +1235,141 @@ async def rechercher_articles(query: Optional[str] = Query(None)): logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) + @app.post( "/articles", response_model=ArticleResponse, status_code=status.HTTP_201_CREATED, - tags=["Articles"] + tags=["Articles"], ) async def creer_article(article: ArticleCreateRequest): - """ - ➕ Création d'un nouvel article dans Sage - - **Usage typique:** Créer un article avec stock pour éviter l'erreur 2881 - - **Champs obligatoires:** - - `reference` (max 18 caractères) : Référence unique de l'article - - `designation` (max 69 caractères) : Désignation de l'article - - **Champs optionnels mais recommandés:** - - `stock_reel` : Stock initial (important pour éviter erreurs de transformation) - - `prix_vente` : Prix de vente HT - - `unite_vente` : Unité de vente (défaut: "UN") - - **Erreurs possibles:** - - 400: Article existe déjà ou données invalides - - 500: Erreur Sage (problème de connexion, champs mal formatés, etc.) - - **Exemple:** - ```json - { - "reference": "ART001", - "designation": "Article de test", - "prix_vente": 10.50, - "stock_reel": 100.0, - "stock_mini": 10.0, - "unite_vente": "UN", - "tva_code": "C20" - } - ``` - """ try: # Validation des données if not article.reference or not article.designation: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Les champs 'reference' et 'designation' sont obligatoires" + detail="Les champs 'reference' et 'designation' sont obligatoires", ) - - # ⚠️ CORRECTION: Ne pas utiliser exclude_none=True car on veut garder - # les valeurs par défaut (comme unite_vente="UN") + article_data = article.dict(exclude_unset=True) - + logger.info(f"📝 Création article: {article.reference} - {article.designation}") - + # Appel à la gateway Windows resultat = sage_client.creer_article(article_data) - - logger.info(f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})") - + + logger.info( + f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" + ) + return ArticleResponse(**resultat) - + except ValueError as e: # Erreur métier (ex: article existe déjà) logger.warning(f"⚠️ Erreur métier création article: {e}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except HTTPException: raise - + except Exception as e: # Erreur technique Sage logger.error(f"❌ Erreur technique création article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la création de l'article: {str(e)}" + detail=f"Erreur lors de la création de l'article: {str(e)}", ) @app.put("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), - article: ArticleUpdateRequest = Body(...) + article: ArticleUpdateRequest = Body(...), ): - """ - ✏️ Modification complète d'un article existant - - **Usage critique:** Augmenter le stock pour résoudre l'erreur 2881 - - **Erreur 2881 - "L'état du stock ne permet pas de créer la ligne"** - - Cette erreur survient lors de la transformation de documents (devis → commande → facture) - lorsque le stock de l'article est insuffisant. - - **Solution:** Augmenter le `stock_reel` de l'article - - **Exemple - Résoudre l'erreur 2881:** - ```json - { - "stock_reel": 100.0 - } - ``` - - **Autres modifications possibles:** - - Prix de vente/achat - - Stock minimum - - Code EAN - - Description - - **Erreurs possibles:** - - 404: Article introuvable - - 400: Aucun champ à modifier ou données invalides - - 500: Erreur Sage - - **Note:** Seuls les champs fournis seront modifiés, les autres restent inchangés - """ try: - # ✅ CORRECTION: Utiliser exclude_unset=True au lieu de exclude_none=True - # Cela permet de distinguer entre: - # - Champ non fourni (exclu) - # - Champ fourni avec valeur None (inclus pour reset) article_data = article.dict(exclude_unset=True) - + if not article_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour." + detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour.", ) - + logger.info(f"📝 Modification article {reference}: {list(article_data.keys())}") - + # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) - + # Log spécial pour modification de stock (important pour erreur 2881) if "stock_reel" in article_data: logger.info( f"📦 Stock {reference} modifié: {article_data['stock_reel']} " f"(peut résoudre erreur 2881)" ) - + logger.info(f"✅ Article {reference} modifié ({len(article_data)} champs)") - + return ArticleResponse(**resultat) - + except ValueError as e: # Erreur métier (ex: article introuvable) logger.warning(f"⚠️ Erreur métier modification article: {e}") - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except HTTPException: raise - + except Exception as e: # Erreur technique Sage logger.error(f"❌ Erreur technique modification article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la modification de l'article: {str(e)}" + detail=f"Erreur lors de la modification de l'article: {str(e)}", ) @app.get("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) -async def lire_article(reference: str = Path(..., description="Référence de l'article")): - """ - 📄 Lecture d'un article spécifique par référence - - **Retourne:** - - Toutes les informations de l'article - - Stock actuel (réel, réservé, disponible) - - Prix de vente et d'achat - - Famille, fournisseur principal - - Caractéristiques physiques (poids, volume) - - **Source:** Cache mémoire (instantané) - """ +async def lire_article( + reference: str = Path(..., description="Référence de l'article") +): try: article = sage_client.lire_article(reference) - + if not article: logger.warning(f"⚠️ Article {reference} introuvable") raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Article {reference} introuvable" + detail=f"Article {reference} introuvable", ) - + logger.info(f"✅ Article {reference} lu: {article.get('designation', '')}") - + return ArticleResponse(**article) - + except HTTPException: raise except Exception as e: logger.error(f"❌ Erreur lecture article {reference}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erreur lors de la lecture de l'article: {str(e)}" + detail=f"Erreur lors de la lecture de l'article: {str(e)}", ) + @app.get("/articles/all") def lister_articles(filtre: str = ""): - """ - 📋 Liste tous les articles avec filtre optionnel - """ try: articles = sage_client.lister_articles(filtre) - - return { - "articles": articles, - "total": len(articles) - } - + + return {"articles": articles, "total": len(articles)} + except Exception as e: logger.error(f"Erreur liste articles: {e}") raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e)) - - + + @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): - """📝 Création de devis via gateway Windows""" try: # Préparer les données pour la gateway devis_data = { @@ -1359,26 +1411,6 @@ async def modifier_devis( devis_update: DevisUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un devis existant - - **Champs modifiables:** - - `date_devis`: Nouvelle date du devis - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé) - - **Note importante:** - - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes - - Un devis transformé (statut=5) ne peut plus être modifié - - Args: - id: Numéro du devis à modifier - devis_update: Champs à mettre à jour - - Returns: - Devis modifié avec ses nouvelles valeurs - """ try: # Vérifier que le devis existe devis_existant = sage_client.lire_devis(id) @@ -1433,27 +1465,6 @@ async def modifier_devis( async def creer_commande( commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une nouvelle commande (Bon de commande) - - **Workflow typique:** - 1. Création d'un devis → transformation en commande (automatique) - 2. OU création directe d'une commande (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_commande`: Date de la commande (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - - Args: - commande: Données de la commande à créer - - Returns: - Commande créée avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(commande.client_id) @@ -1510,29 +1521,6 @@ async def modifier_commande( commande_update: CommandeUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une commande existante - - **Champs modifiables:** - - `date_commande`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Une commande transformée (statut=5) ne peut plus être modifiée - - Une commande annulée (statut=6) ne peut plus être modifiée - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de la commande à modifier - commande_update: Champs à mettre à jour - - Returns: - Commande modifiée avec ses nouvelles valeurs - """ try: # Vérifier que la commande existe commande_existante = sage_client.lire_document( @@ -1605,16 +1593,6 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), ): - """ - 📋 Liste tous les devis via gateway Windows - - Args: - limit: Nombre maximum de devis à retourner - statut: Filtre par statut (0=Devis, 2=Accepté, 5=Transformé, etc.) - inclure_lignes: Si True, charge les lignes de chaque devis (par défaut: True) - - ✅ AMÉLIORATION: Les lignes sont maintenant incluses par défaut - """ try: devis_list = sage_client.lister_devis( limit=limit, statut=statut, inclure_lignes=inclure_lignes @@ -1628,18 +1606,6 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): - """ - 📄 Lecture d'un devis via gateway Windows - - Returns: - Devis complet avec: - - Toutes les informations standards - - lignes: Lignes du devis - - a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé - - documents_cibles: ✅ Liste des documents créés depuis ce devis - - ✅ ENRICHI: Inclut maintenant l'information de transformation - """ try: devis = sage_client.lire_devis(id) @@ -1665,7 +1631,6 @@ async def lire_devis(id: str): @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): - """📄 Téléchargement PDF (généré via email_queue)""" try: # Générer PDF en appelant la méthode de email_queue # qui elle-même appellera sage_client pour récupérer les données @@ -1689,31 +1654,6 @@ async def telecharger_document_pdf( ), numero: str = Path(..., description="Numéro du document"), ): - """ - 📄 Téléchargement PDF d'un document (route généralisée) - - **Types de documents supportés:** - - `0`: Devis - - `10`: Bon de commande - - `30`: Bon de livraison - - `60`: Facture - - `50`: Bon d'avoir - - **Exemple d'utilisation:** - - `GET /documents/0/DE00001/pdf` → PDF du devis DE00001 - - `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001 - - **Retour:** - - Fichier PDF prêt à télécharger - - Nom de fichier formaté selon le type de document - - Args: - type_doc: Type de document Sage (0-60) - numero: Numéro du document - - Returns: - StreamingResponse avec le PDF - """ try: # Mapping des types vers les libellés types_labels = { @@ -1771,7 +1711,6 @@ async def telecharger_document_pdf( async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): - """📧 Envoi devis par email""" try: # Vérifier que le devis existe devis = sage_client.lire_devis(id) @@ -1828,26 +1767,6 @@ async def changer_statut_devis( ..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé" ), ): - """ - 📊 Changement de statut d'un devis - - **Statuts possibles:** - - 0: Brouillon - - 2: Accepté/Validé - - 5: Transformé (automatique lors d'une transformation) - - 6: Annulé - - **Restrictions:** - - Un devis transformé (5) ne peut plus changer de statut - - Un devis annulé (6) ne peut plus changer de statut - - Args: - id: Numéro du devis - nouveau_statut: Nouveau statut (0-6) - - Returns: - Confirmation du changement avec ancien et nouveau statut - """ try: # Vérifier que le devis existe devis_existant = sage_client.lire_devis(id) @@ -1886,15 +1805,8 @@ async def changer_statut_devis( logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) - -# ===================================================== -# ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE) -# ===================================================== - - @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): - """📄 Lecture d'une commande avec ses lignes""" try: commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande: @@ -1911,12 +1823,6 @@ async def lire_commande(id: str): async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les commandes via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_commandes() qui existe déjà - Le filtrage sur type 10 est fait côté Windows dans main.py - """ try: commandes = sage_client.lister_commandes(limit=limit, statut=statut) return commandes @@ -1928,11 +1834,6 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Devis → Commande - ✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10) - ✅ Met à jour le statut du devis source à 5 (Transformé) - """ try: # Étape 1: Transformation resultat = sage_client.transformer_document( @@ -1985,10 +1886,6 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi @app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Commande → Facture - ✅ Utilise les VRAIS types Sage (10 → 60) - """ try: resultat = sage_client.transformer_document( numero_source=id, @@ -2033,7 +1930,6 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses async def envoyer_signature( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): - """✍️ Envoi document pour signature Universign""" try: # Générer PDF pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc) @@ -2084,7 +1980,6 @@ async def envoyer_signature( @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): - """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale try: async with async_session_factory() as session: @@ -2116,7 +2011,6 @@ async def lister_signatures( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): - """📋 Liste toutes les demandes de signature""" query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) if statut: @@ -2152,7 +2046,6 @@ async def lister_signatures( async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): - """🔍 Récupération du statut détaillé d'une signature""" query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id) result = await session.execute(query) signature_log = result.scalar_one_or_none() @@ -2204,7 +2097,6 @@ async def statut_signature_detail( @app.post("/signatures/refresh-all", tags=["Signatures"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): - """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( SignatureLog.statut.in_( [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] @@ -2255,7 +2147,6 @@ async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_sess async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): - """✏️ Envoi d'un devis pour signature électronique""" try: # Vérifier devis via gateway Windows devis = sage_client.lire_devis(id) @@ -2307,11 +2198,6 @@ async def envoyer_devis_signature( raise HTTPException(500, str(e)) -# ============================================ -# US-A4 - ENVOI EMAILS EN LOT -# ============================================ - - class EmailBatchRequest(BaseModel): destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) sujet: str = Field(..., min_length=1, max_length=500) @@ -2324,7 +2210,6 @@ class EmailBatchRequest(BaseModel): async def envoyer_emails_lot( batch: EmailBatchRequest, session: AsyncSession = Depends(get_session) ): - """📧 US-A4: Envoi groupé via email_queue""" resultats = [] for destinataire in batch.destinataires: @@ -2368,12 +2253,6 @@ async def envoyer_emails_lot( "details": resultats, } - -# ===================================================== -# ENDPOINTS - US-A5 -# ===================================================== - - @app.post( "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] ) @@ -2381,11 +2260,7 @@ async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), ): - """ - 💰 US-A5: Validation remise via barème client Sage - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) remise_max = sage_client.lire_remise_max_client(client_id) autorisee = remise_pourcentage <= remise_max @@ -2411,14 +2286,10 @@ async def valider_remise( raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A6 (RELANCE DEVIS) -# ===================================================== @app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): - """📧 Relance devis via Universign""" try: # Lire devis via gateway devis = sage_client.lire_devis(id) @@ -2487,7 +2358,6 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): - """👤 US-A6: Récupération du contact client associé au devis""" try: # Lire devis via gateway Windows devis = sage_client.lire_devis(id) @@ -2520,12 +2390,6 @@ async def recuperer_contact_devis(id: str): async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les factures via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_factures() qui existe déjà - Le filtrage sur type 60 est fait côté Windows dans main.py - """ try: factures = sage_client.lister_factures(limit=limit, statut=statut) return factures @@ -2537,15 +2401,6 @@ async def lister_factures( @app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail(numero: str): - """ - 📄 Lecture détaillée d'une facture avec ses lignes - - Args: - numero: Numéro de la facture (ex: "FA000001") - - Returns: - Facture complète avec lignes, client, totaux, etc. - """ try: facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) @@ -2570,32 +2425,6 @@ class RelanceFactureRequest(BaseModel): async def creer_facture( facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une facture - - **Workflow typique:** - 1. Commande → Livraison → Facture (transformations successives) - 2. OU création directe d'une facture (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_facture`: Date de la facture (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - - **Notes importantes:** - - Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage - - Le statut initial est généralement 2 (Accepté/Validé) - - Les factures sont soumises aux règles de numérotation strictes - - Args: - facture: Données de la facture à créer - - Returns: - Facture créée avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(facture.client_id) @@ -2652,31 +2481,6 @@ async def modifier_facture( facture_update: FactureUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une facture existante - - **Champs modifiables:** - - `date_facture`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions IMPORTANTES:** - - Une facture transformée (statut=5) ne peut plus être modifiée - - Une facture annulée (statut=6) ne peut plus être modifiée - - ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage - - Certaines factures peuvent être en lecture seule selon les droits utilisateur - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de la facture à modifier - facture_update: Champs à mettre à jour - - Returns: - Facture modifiée avec ses nouvelles valeurs - """ try: # Vérifier que la facture existe facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE) @@ -2739,7 +2543,6 @@ async def modifier_facture( raise HTTPException(500, str(e)) -# Templates email (si pas déjà définis) templates_email_db = { "relance_facture": { "id": "relance_facture", @@ -2769,7 +2572,6 @@ async def relancer_facture( relance: RelanceFactureRequest, session: AsyncSession = Depends(get_session), ): - """💸 US-A7: Relance facture en un clic""" try: # Lire facture via gateway Windows facture = sage_client.lire_document(id, TypeDocument.FACTURE) @@ -2818,7 +2620,6 @@ async def relancer_facture( # Enqueue email_queue.enqueue(email_log.id) - # ✅ MAJ champ libre via gateway Windows sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) await session.commit() @@ -2837,12 +2638,6 @@ async def relancer_facture( logger.error(f"Erreur relance facture: {e}") raise HTTPException(500, str(e)) - -# ============================================ -# US-A9 - JOURNAL DES E-MAILS -# ============================================ - - @app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), @@ -2850,7 +2645,6 @@ async def journal_emails( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): - """📋 US-A9: Journal des e-mails envoyés""" query = select(EmailLog) if statut: @@ -2885,7 +2679,6 @@ async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), ): - """📥 US-A9: Export CSV des logs d'envoi""" query = select(EmailLog) if statut: query = query.where(EmailLog.statut == StatutEmailEnum[statut.value]) @@ -2940,12 +2733,6 @@ async def exporter_logs_csv( }, ) - -# ============================================ -# Devis0 - MODÈLES D'E-MAILS -# ============================================ - - class TemplateEmail(BaseModel): id: Optional[str] = None nom: str @@ -2962,7 +2749,6 @@ class TemplatePreviewRequest(BaseModel): @app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) async def lister_templates(): - """📧 Emails: Liste tous les templates d'emails""" return [TemplateEmail(**template) for template in templates_email_db.values()] @@ -2970,7 +2756,6 @@ async def lister_templates(): "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def lire_template(template_id: str): - """📖 Lecture d'un template par ID""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -2979,7 +2764,6 @@ async def lire_template(template_id: str): @app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) async def creer_template(template: TemplateEmail): - """➕ Création d'un nouveau template""" template_id = str(uuid.uuid4()) templates_email_db[template_id] = { @@ -2999,7 +2783,6 @@ async def creer_template(template: TemplateEmail): "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def modifier_template(template_id: str, template: TemplateEmail): - """✏️ Modification d'un template existant""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -3022,7 +2805,6 @@ async def modifier_template(template_id: str, template: TemplateEmail): @app.delete("/templates/emails/{template_id}", tags=["Emails"]) async def supprimer_template(template_id: str): - """🗑️ Suppression d'un template""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -3038,7 +2820,6 @@ async def supprimer_template(template_id: str): @app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email(preview: TemplatePreviewRequest): - """👁️ US-A10: Prévisualisation email avec fusion variables""" if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -3080,7 +2861,6 @@ async def previsualiser_email(preview: TemplatePreviewRequest): # ===================================================== @app.get("/health", tags=["System"]) async def health_check(): - """🏥 Health check""" gateway_health = sage_client.health() return { @@ -3097,7 +2877,6 @@ async def health_check(): @app.get("/", tags=["System"]) async def root(): - """🏠 Page d'accueil""" return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", @@ -3113,11 +2892,7 @@ async def root(): @app.get("/admin/cache/info", tags=["Admin"]) async def info_cache(): - """ - 📊 Informations sur l'état du cache Windows - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) cache_info = sage_client.get_cache_info() return cache_info @@ -3126,34 +2901,8 @@ async def info_cache(): raise HTTPException(500, str(e)) -# 🔧 CORRECTION ADMIN: Cache refresh (doit appeler Windows) -@app.post("/admin/cache/refresh", tags=["Admin"]) -async def forcer_actualisation(): - """ - 🔄 Force l'actualisation du cache Windows - """ - try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) - resultat = sage_client.refresh_cache() - cache_info = sage_client.get_cache_info() - - return { - "success": True, - "message": "Cache actualisé sur Windows Server", - "info": cache_info, - } - - except Exception as e: - logger.error(f"Erreur refresh cache: {e}") - raise HTTPException(500, str(e)) - - -# ✅ RESTE INCHANGÉ: admin/queue/status (local au VPS) @app.get("/admin/queue/status", tags=["Admin"]) async def statut_queue(): - """ - 📊 Statut de la queue d'emails (local VPS) - """ return { "queue_size": email_queue.queue.qsize(), "workers": len(email_queue.workers), @@ -3166,7 +2915,6 @@ async def statut_queue(): # ===================================================== @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects(query: Optional[str] = Query(None)): - """🔍 Recherche prospects via gateway Windows""" try: prospects = sage_client.lister_prospects(filtre=query or "") return prospects @@ -3177,7 +2925,6 @@ async def rechercher_prospects(query: Optional[str] = Query(None)): @app.get("/prospects/{code}", tags=["Prospects"]) async def lire_prospect(code: str): - """📄 Lecture d'un prospect par code""" try: prospect = sage_client.lire_prospect(code) if not prospect: @@ -3195,9 +2942,6 @@ async def lire_prospect(code: str): # ===================================================== @app.get("/fournisseurs", tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): - """ - 🔍 Recherche fournisseurs via gateway Windows - """ try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") @@ -3218,26 +2962,6 @@ async def ajouter_fournisseur( fournisseur: FournisseurCreateAPIRequest, session: AsyncSession = Depends(get_session), ): - """ - ➕ Création d'un nouveau fournisseur dans Sage 100c - - **Champs obligatoires:** - - `intitule`: Raison sociale (max 69 caractères) - - **Champs optionnels:** - - `compte_collectif`: Compte comptable (défaut: 401000) - - `num`: Code fournisseur personnalisé (auto-généré si vide) - - `adresse`, `code_postal`, `ville`, `pays` - - `email`, `telephone` - - `siret`, `tva_intra` - - **Retour:** - - Fournisseur créé avec son numéro définitif - - **Erreurs possibles:** - - 400: Fournisseur existe déjà (doublon) - - 500: Erreur technique Sage - """ try: # Appel à la gateway Windows via sage_client nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) @@ -3267,23 +2991,6 @@ async def modifier_fournisseur( fournisseur_update: FournisseurUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un fournisseur existant - - Args: - code: Code du fournisseur à modifier - fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés) - - Returns: - Fournisseur modifié avec ses nouvelles valeurs - - Example: - PUT /fournisseurs/DUPONT - { - "email": "nouveau@email.fr", - "telephone": "0198765432" - } - """ try: # Appel à la gateway Windows resultat = sage_client.modifier_fournisseur( @@ -3310,7 +3017,6 @@ async def modifier_fournisseur( @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): - """📄 Lecture d'un fournisseur par code""" try: fournisseur = sage_client.lire_fournisseur(code) if not fournisseur: @@ -3330,7 +3036,6 @@ async def lire_fournisseur(code: str): async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """📋 Liste tous les avoirs via gateway Windows""" try: avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) return avoirs @@ -3341,7 +3046,6 @@ async def lister_avoirs( @app.get("/avoirs/{numero}", tags=["Avoirs"]) async def lire_avoir(numero: str): - """📄 Lecture d'un avoir avec ses lignes""" try: avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) if not avoir: @@ -3358,29 +3062,6 @@ async def lire_avoir(numero: str): async def creer_avoir( avoir: AvoirCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'un avoir (Bon d'avoir) - - **Workflow typique:** - 1. Retour marchandise → création d'un avoir - 2. Geste commercial → création directe d'un avoir (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_avoir`: Date de l'avoir (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de retour) - - **Note:** Les montants des avoirs sont généralement négatifs (crédits) - - Args: - avoir: Données de l'avoir à créer - - Returns: - Avoir créé avec son numéro et ses totaux - """ try: # Vérifier que le client existe client = sage_client.lire_client(avoir.client_id) @@ -3435,29 +3116,6 @@ async def modifier_avoir( avoir_update: AvoirUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'un avoir existant - - **Champs modifiables:** - - `date_avoir`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Un avoir transformé (statut=5) ne peut plus être modifié - - Un avoir annulé (statut=6) ne peut plus être modifié - - **Note importante:** - Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées - - Args: - id: Numéro de l'avoir à modifier - avoir_update: Champs à mettre à jour - - Returns: - Avoir modifié avec ses nouvelles valeurs - """ try: # Vérifier que l'avoir existe avoir_existant = sage_client.lire_avoir(id) @@ -3526,7 +3184,6 @@ async def modifier_avoir( async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """📋 Liste tous les bons de livraison via gateway Windows""" try: livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) return livraisons @@ -3537,7 +3194,6 @@ async def lister_livraisons( @app.get("/livraisons/{numero}", tags=["Livraisons"]) async def lire_livraison(numero: str): - """📄 Lecture d'une livraison avec ses lignes""" try: livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) if not livraison: @@ -3554,21 +3210,6 @@ async def lire_livraison(numero: str): async def creer_livraison( livraison: LivraisonCreateRequest, session: AsyncSession = Depends(get_session) ): - """ - ➕ Création d'une nouvelle livraison (Bon de livraison) - - **Workflow typique:** - 1. Création d'une commande → transformation en livraison (automatique) - 2. OU création directe d'une livraison (cette route) - - **Champs obligatoires:** - - `client_id`: Code du client - - `lignes`: Liste des lignes (min 1) - - **Champs optionnels:** - - `date_livraison`: Date de la livraison (par défaut: aujourd'hui) - - `reference`: Référence externe (ex: numéro de commande client) - """ try: # Vérifier que le client existe client = sage_client.lire_client(livraison.client_id) @@ -3627,19 +3268,6 @@ async def modifier_livraison( livraison_update: LivraisonUpdateRequest, session: AsyncSession = Depends(get_session), ): - """ - ✏️ Modification d'une livraison existante - - **Champs modifiables:** - - `date_livraison`: Nouvelle date - - `lignes`: Nouvelles lignes (remplace toutes les lignes existantes) - - `statut`: Nouveau statut - - `reference`: Référence externe - - **Restrictions:** - - Une livraison transformée (statut=5) ne peut plus être modifiée - - Une livraison annulée (statut=6) ne peut plus être modifiée - """ try: # Vérifier que la livraison existe livraison_existante = sage_client.lire_livraison(id) @@ -3704,10 +3332,6 @@ async def modifier_livraison( @app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Livraison → Facture - ✅ Utilise les VRAIS types Sage (30 → 60) - """ try: resultat = sage_client.transformer_document( numero_source=id, @@ -3749,26 +3373,6 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session) ): - """ - 🔧 Transformation Devis → Facture (DIRECT, sans commande) - - ✅ Utilise les VRAIS types Sage (0 → 60) - ✅ Met à jour le statut du devis source à 5 (Transformé) - - **Workflow raccourci** : Permet de facturer directement depuis un devis - sans passer par la création d'une commande. - - **Cas d'usage** : - - Prestations de services facturées directement - - Petites commandes sans besoin de suivi intermédiaire - - Ventes au comptoir - - Args: - id: Numéro du devis source - - Returns: - Informations de la facture créée - """ try: # Étape 1: Vérifier que le devis n'a pas déjà été transformé devis_existant = sage_client.lire_devis(id) @@ -3840,30 +3444,6 @@ async def devis_vers_facture_direct( async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session) ): - """ - 🔧 Transformation Commande → Bon de livraison - - ✅ Utilise les VRAIS types Sage (10 → 30) - - **Workflow typique** : Après validation d'une commande, génère - le bon de livraison pour préparer l'expédition. - - **Cas d'usage** : - - Préparation d'une expédition - - Génération du bordereau de livraison - - Suivi logistique - - **Workflow complet** : - 1. Devis → Commande (via `/workflow/devis/{id}/to-commande`) - 2. **Commande → Livraison** (cette route) - 3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`) - - Args: - id: Numéro de la commande source - - Returns: - Informations du bon de livraison créé - """ try: # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document( @@ -3930,6 +3510,231 @@ async def commande_vers_livraison( raise HTTPException(500, str(e)) +@app.get( + "/familles", + response_model=List[FamilleResponse], + tags=["Familles"], + summary="Liste toutes les familles d'articles", +) +async def lister_familles( + filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé") +): + try: + familles = sage_client.lister_familles(filtre or "") + + logger.info(f"✅ {len(familles)} famille(s) retournée(s)") + + return [FamilleResponse(**f) for f in familles] + + except Exception as e: + logger.error(f"❌ Erreur liste familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des familles: {str(e)}", + ) + + +@app.get( + "/familles/{code}", + response_model=FamilleResponse, + tags=["Familles"], + summary="Lecture d'une famille par son code", +) +async def lire_famille( + code: str = Path(..., description="Code de la famille (ex: ZDIVERS)") +): + try: + famille = sage_client.lire_famille(code) + + if not famille: + logger.warning(f"⚠️ Famille {code} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Famille {code} introuvable", + ) + + logger.info(f"✅ Famille {code} lue: {famille.get('intitule', '')}") + + return FamilleResponse(**famille) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture famille {code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture de la famille: {str(e)}", + ) + + +@app.post( + "/familles", + response_model=FamilleResponse, + status_code=status.HTTP_201_CREATED, + tags=["Familles"], + summary="Création d'une famille d'articles", +) +async def creer_famille(famille: FamilleCreateRequest): + try: + # Validation des données + if not famille.code or not famille.intitule: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'code' et 'intitule' sont obligatoires", + ) + + famille_data = famille.dict() + + logger.info(f"📦 Création famille: {famille.code} - {famille.intitule}") + + # Appel à la gateway Windows + resultat = sage_client.creer_famille(famille_data) + + logger.info(f"✅ Famille créée: {resultat.get('code')}") + + return FamilleResponse(**resultat) + + except ValueError as e: + # Erreur métier (ex: famille existe déjà) + logger.warning(f"⚠️ Erreur métier création famille: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + # Erreur technique Sage + logger.error(f"❌ Erreur technique création famille: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la famille: {str(e)}", + ) + + +@app.post( + "/stock/entree", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock" +) +async def creer_entree_stock(entree: EntreeStockRequest): + try: + # Préparer les données + entree_data = entree.dict() + + logger.info(f"📦 Création entrée stock: {len(entree.lignes)} ligne(s)") + + # Appel à la gateway Windows + resultat = sage_client.creer_entree_stock(entree_data) + + logger.info(f"✅ Entrée stock créée: {resultat.get('numero')}") + + return MouvementStockResponse(**resultat) + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier entrée stock: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except Exception as e: + logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'entrée: {str(e)}" + ) + + +@app.post( + "/stock/sortie", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="SORTIE DE STOCK : Retire des articles du stock" +) +async def creer_sortie_stock(sortie: SortieStockRequest): + try: + # Préparer les données + sortie_data = sortie.dict() + + logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") + + # Appel à la gateway Windows + resultat = sage_client.creer_sortie_stock(sortie_data) + + logger.info(f"✅ Sortie stock créée: {resultat.get('numero')}") + + return MouvementStockResponse(**resultat) + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier sortie stock: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + except Exception as e: + logger.error(f"❌ Erreur technique sortie stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la sortie: {str(e)}" + ) + + +@app.get( + "/stock/mouvement/{numero}", + response_model=MouvementStockResponse, + tags=["Stock"], + summary="Lecture d'un mouvement de stock" +) +async def lire_mouvement_stock( + numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)") +): + try: + mouvement = sage_client.lire_mouvement_stock(numero) + + if not mouvement: + logger.warning(f"⚠️ Mouvement {numero} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mouvement de stock {numero} introuvable" + ) + + logger.info(f"✅ Mouvement {numero} lu") + + return MouvementStockResponse(**mouvement) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Erreur lecture mouvement {numero}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture du mouvement: {str(e)}" + ) + + +@app.get( + "/familles/stats/global", + tags=["Familles"], + summary="Statistiques sur les familles", +) +async def statistiques_familles(): + try: + stats = sage_client.get_stats_familles() + + return {"success": True, "data": stats} + + except Exception as e: + logger.error(f"❌ Erreur stats familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des statistiques: {str(e)}", + ) + + @app.get("/debug/users", response_model=List[UserResponse], tags=["Debug"]) async def lister_utilisateurs_debug( session: AsyncSession = Depends(get_session), @@ -3937,20 +3742,6 @@ async def lister_utilisateurs_debug( role: Optional[str] = Query(None), verified_only: bool = Query(False), ): - """ - 🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits - - ⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification. - À utiliser uniquement en développement ou à sécuriser en production. - - Args: - limit: Nombre maximum d'utilisateurs à retourner - role: Filtrer par rôle (user, admin, commercial) - verified_only: Afficher uniquement les utilisateurs vérifiés - - Returns: - Liste des utilisateurs avec leurs informations (mot de passe masqué) - """ from database import User from sqlalchemy import select @@ -4003,11 +3794,6 @@ async def lister_utilisateurs_debug( @app.get("/debug/users/stats", tags=["Debug"]) async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)): - """ - 📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs - - ⚠️ Non protégée - à sécuriser en production - """ from database import User from sqlalchemy import select, func diff --git a/sage_client.py b/sage_client.py index 9c1b2c4..6b9325c 100644 --- a/sage_client.py +++ b/sage_client.py @@ -63,9 +63,6 @@ class SageGatewayClient: raise time.sleep(2**attempt) - # ===================================================== - # CLIENTS - # ===================================================== def lister_clients(self, filtre: str = "") -> List[Dict]: """Liste tous les clients avec filtre optionnel""" return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) @@ -74,9 +71,6 @@ class SageGatewayClient: """Lecture d'un client par code""" return self._post("/sage/clients/get", {"code": code}).get("data") - # ===================================================== - # ARTICLES - # ===================================================== def lister_articles(self, filtre: str = "") -> List[Dict]: """Liste tous les articles avec filtre optionnel""" return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) @@ -85,9 +79,6 @@ class SageGatewayClient: """Lecture d'un article par référence""" return self._post("/sage/articles/get", {"code": ref}).get("data") - # ===================================================== - # DEVIS (US-A1) - # ===================================================== def creer_devis(self, devis_data: Dict) -> Dict: """Création d'un devis""" return self._post("/sage/devis/create", devis_data).get("data", {}) @@ -102,18 +93,12 @@ class SageGatewayClient: statut: Optional[int] = None, inclure_lignes: bool = True, ) -> List[Dict]: - """ - ✅ Liste tous les devis avec filtres - """ payload = {"limit": limit, "inclure_lignes": inclure_lignes} if statut is not None: payload["statut"] = statut return self._post("/sage/devis/list", payload).get("data", []) def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: - """ - ✅ CORRECTION: Utilise query params au lieu du body - """ try: r = requests.post( f"{self.url}/sage/devis/statut", @@ -130,9 +115,6 @@ class SageGatewayClient: logger.error(f"❌ Erreur changement statut: {e}") raise - # ===================================================== - # DOCUMENTS GÉNÉRIQUES - # ===================================================== def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: """Lecture d'un document générique""" return self._post( @@ -142,9 +124,6 @@ class SageGatewayClient: def transformer_document( self, numero_source: str, type_source: int, type_cible: int ) -> Dict: - """ - ✅ CORRECTION: Utilise query params pour la transformation - """ try: r = requests.post( f"{self.url}/sage/documents/transform", @@ -177,15 +156,9 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # COMMANDES (US-A2) - # ===================================================== def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - Utilise l'endpoint /sage/commandes/list qui filtre déjà sur type 10 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -194,10 +167,6 @@ class SageGatewayClient: def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - ✅ Liste toutes les factures - Utilise l'endpoint /sage/factures/list qui filtre déjà sur type 60 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -210,24 +179,15 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # CONTACTS (US-A6) - # ===================================================== def lire_contact_client(self, code_client: str) -> Optional[Dict]: """Lecture du contact principal d'un client""" return self._post("/sage/contact/read", {"code": code_client}).get("data") - # ===================================================== - # REMISES (US-A5) - # ===================================================== def lire_remise_max_client(self, code_client: str) -> float: """Récupère la remise max autorisée pour un client""" result = self._post("/sage/client/remise-max", {"code": code_client}) return result.get("data", {}).get("remise_max", 10.0) - # ===================================================== - # GÉNÉRATION PDF (pour email_queue) - # ===================================================== def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """Génère le PDF d'un document via la gateway Windows""" try: @@ -253,9 +213,6 @@ class SageGatewayClient: logger.error(f"Erreur génération PDF: {e}") raise - # ===================================================== - # PROSPECTS - # ===================================================== def lister_prospects(self, filtre: str = "") -> List[Dict]: """Liste tous les prospects avec filtre optionnel""" return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) @@ -264,9 +221,6 @@ class SageGatewayClient: """Lecture d'un prospect par code""" return self._post("/sage/prospects/get", {"code": code}).get("data") - # ===================================================== - # FOURNISSEURS - # ===================================================== def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: """Liste tous les fournisseurs avec filtre optionnel""" return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) @@ -276,37 +230,14 @@ class SageGatewayClient: return self._post("/sage/fournisseurs/get", {"code": code}).get("data") def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: - """ - Envoie la requête de création de fournisseur à la gateway Windows. - - Args: - fournisseur_data: Dict contenant intitule, compte_collectif, etc. - - Returns: - Fournisseur créé avec son numéro définitif - """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: - """ - ✏️ Modification d'un fournisseur existant - - Args: - code: Code du fournisseur à modifier - fournisseur_data: Dictionnaire contenant les champs à modifier - (seuls les champs présents seront mis à jour) - - Returns: - Fournisseur modifié - """ return self._post( "/sage/fournisseurs/update", {"code": code, "fournisseur_data": fournisseur_data}, ).get("data", {}) - # ===================================================== - # AVOIRS - # ===================================================== def lister_avoirs( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -320,9 +251,6 @@ class SageGatewayClient: """Lecture d'un avoir avec ses lignes""" return self._post("/sage/avoirs/get", {"code": numero}).get("data") - # ===================================================== - # LIVRAISONS - # ===================================================== def lister_livraisons( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: @@ -359,206 +287,52 @@ class SageGatewayClient: return {"status": "down"} def creer_client(self, client_data: Dict) -> Dict: - """ - Envoie la requête de création de client à la gateway Windows. - :param client_data: Dict contenant intitule, compte_collectif, etc. - """ - # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) def modifier_client(self, code: str, client_data: Dict) -> Dict: - """ - ✏️ Modification d'un client existant - - Args: - code: Code du client à modifier - client_data: Dictionnaire contenant les champs à modifier - (seuls les champs présents seront mis à jour) - - Returns: - Client modifié - """ return self._post( "/sage/clients/update", {"code": code, "client_data": client_data} ).get("data", {}) def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: - """ - ✏️ Modification d'un devis existant - - Args: - numero: Numéro du devis à modifier - devis_data: Dictionnaire contenant les champs à modifier: - - date_devis (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - Returns: - Devis modifié avec totaux recalculés - """ return self._post( "/sage/devis/update", {"numero": numero, "devis_data": devis_data} ).get("data", {}) def creer_commande(self, commande_data: Dict) -> Dict: - """ - ➕ Création d'une nouvelle commande (Bon de commande) - - Args: - commande_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_commande (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Commande créée avec son numéro et ses totaux - """ return self._post("/sage/commandes/create", commande_data).get("data", {}) def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: - """ - ✏️ Modification d'une commande existante - - Args: - numero: Numéro de la commande à modifier - commande_data: Dictionnaire contenant les champs à modifier: - - date_commande (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Commande modifiée avec totaux recalculés - """ return self._post( "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} ).get("data", {}) def creer_livraison(self, livraison_data: Dict) -> Dict: - """ - ➕ Création d'une nouvelle livraison (Bon de livraison) - """ return self._post("/sage/livraisons/create", livraison_data).get("data", {}) def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: - """ - ✏️ Modification d'une livraison existante - """ return self._post( "/sage/livraisons/update", {"numero": numero, "livraison_data": livraison_data}, ).get("data", {}) def creer_avoir(self, avoir_data: Dict) -> Dict: - """ - ➕ Création d'un avoir (Bon d'avoir) - - Args: - avoir_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_avoir (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Avoir créé avec son numéro et ses totaux - """ return self._post("/sage/avoirs/create", avoir_data).get("data", {}) def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: - """ - ✏️ Modification d'un avoir existant - - Args: - numero: Numéro de l'avoir à modifier - avoir_data: Dictionnaire contenant les champs à modifier: - - date_avoir (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Avoir modifié avec totaux recalculés - """ return self._post( "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} ).get("data", {}) def creer_facture(self, facture_data: Dict) -> Dict: - """ - ➕ Création d'une facture - - Args: - facture_data: Dictionnaire contenant: - - client_id (str): Code du client - - date_facture (str, optional): Date au format ISO - - reference (str, optional): Référence externe - - lignes (List[Dict]): Liste des lignes avec: - - article_code (str) - - quantite (float) - - prix_unitaire_ht (float, optional) - - remise_pourcentage (float, optional) - - Returns: - Facture créée avec son numéro et ses totaux - """ return self._post("/sage/factures/create", facture_data).get("data", {}) def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: - """ - ✏️ Modification d'une facture existante - - Args: - numero: Numéro de la facture à modifier - facture_data: Dictionnaire contenant les champs à modifier: - - date_facture (str, optional): Nouvelle date - - lignes (List, optional): Nouvelles lignes - - statut (int, optional): Nouveau statut - - reference (str, optional): Nouvelle référence - - Returns: - Facture modifiée avec totaux recalculés - """ return self._post( "/sage/factures/update", {"numero": numero, "facture_data": facture_data} ).get("data", {}) def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: - """ - 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) - - **Cette méthode remplace les appels spécifiques par type de document** - - Args: - doc_id: Numéro du document (ex: "DE00001", "FA00001") - type_doc: Type de document Sage: - - 0: Devis - - 10: Bon de commande - - 30: Bon de livraison - - 60: Facture - - 50: Bon d'avoir - - Returns: - bytes: Contenu du PDF (binaire) - - Raises: - ValueError: Si le PDF retourné est vide - RuntimeError: Si erreur de communication avec la gateway - - Example: - >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) - >>> with open("devis.pdf", "wb") as f: - ... f.write(pdf_bytes) - """ try: logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") @@ -610,59 +384,49 @@ class SageGatewayClient: logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) raise - def creer_article(self, article_data: Dict) -> Dict: - """ - ➕ Création d'un article - - Args: - article_data: Dictionnaire contenant: - - reference (str, obligatoire): Référence article - - designation (str, obligatoire): Désignation - - prix_vente (float, optionnel): Prix vente HT - - stock_reel (float, optionnel): Stock initial - - ... (voir ArticleCreateRequest dans main.py) - - Returns: - Article créé - - Example: - >>> article = sage_client.creer_article({ - ... "reference": "ART001", - ... "designation": "Article test", - ... "prix_vente": 10.0, - ... "stock_reel": 100.0 - ... }) - """ return self._post("/sage/articles/create", article_data).get("data", {}) - def modifier_article(self, reference: str, article_data: Dict) -> Dict: - """ - ✏️ Modification d'un article - - **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 - - Args: - reference: Référence de l'article à modifier - article_data: Dictionnaire contenant les champs à modifier: - - stock_reel (float, optionnel): Nouveau stock - - prix_vente (float, optionnel): Nouveau prix - - ... (seuls les champs présents seront mis à jour) - - Returns: - Article modifié - - Example - Résoudre erreur de stock: - >>> # L'erreur 2881 indique un stock insuffisant - >>> sage_client.modifier_article("ART001", { - ... "stock_reel": 100.0 # Augmenter le stock - ... }) - """ return self._post( "/sage/articles/update", - {"reference": reference, "article_data": article_data} + {"reference": reference, "article_data": article_data}, ).get("data", {}) + def lister_familles(self, filtre: str = "") -> List[Dict]: + return self._get("/sage/familles", params={"filtre": filtre}).get("data", []) + + def lire_famille(self, code: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/familles/{code}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture famille {code}: {e}") + return None + + def creer_famille(self, famille_data: Dict) -> Dict: + return self._post("/sage/familles/create", famille_data).get("data", {}) + + def get_stats_familles(self) -> Dict: + return self._get("/sage/familles/stats").get("data", {}) + + + def creer_entree_stock(self, entree_data: Dict) -> Dict: + return self._post("/sage/stock/entree", entree_data).get("data", {}) + + + def creer_sortie_stock(self, sortie_data: Dict) -> Dict: + return self._post("/sage/stock/sortie", sortie_data).get("data", {}) + + + def lire_mouvement_stock(self, numero: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/stock/mouvement/{numero}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture mouvement {numero}: {e}") + return None + + # Instance globale sage_client = SageGatewayClient()