from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict, ClassVar, Any from datetime import date, datetime from enum import Enum, IntEnum import uvicorn from contextlib import asynccontextmanager import uuid import csv import io import logging from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from routes.auth import router as auth_router from config import settings from database import ( init_db, async_session_factory, get_session, EmailLog, StatutEmail as StatutEmailEnum, WorkflowLog, SignatureLog, StatutSignature as StatutSignatureEnum, ) from email_queue import email_queue from sage_client import sage_client from schemas import TiersDetails, TypeTiers logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[logging.FileHandler("sage_api.log"), logging.StreamHandler()], ) logger = logging.getLogger(__name__) TAGS_METADATA = [ { "name": "Clients", "description": "Gestion des clients (recherche, création, modification)", }, {"name": "Articles", "description": "Gestion des articles et produits"}, {"name": "Devis", "description": "Création, consultation et gestion des devis"}, { "name": "Commandes", "description": "Création, consultation et gestion des commandes", }, { "name": "Livraisons", "description": "Création, consultation et gestion des bons de livraison", }, { "name": "Factures", "description": "Création, consultation et gestion des factures", }, {"name": "Avoirs", "description": "Création, consultation et gestion des avoirs"}, {"name": "Fournisseurs", "description": "Gestion des fournisseurs"}, {"name": "Prospects", "description": "Gestion des prospects"}, { "name": "Workflows", "description": "Transformations de documents (devis→commande, commande→facture, etc.)", }, {"name": "Signatures", "description": "Signature électronique via Universign"}, {"name": "Emails", "description": "Envoi d'emails, templates et logs d'envoi"}, {"name": "Validation", "description": "Validation de données (remises, etc.)"}, {"name": "Admin", "description": "🔧 Administration système (cache, queue)"}, {"name": "System", "description": "🏥 Health checks et informations système"}, {"name": "Debug", "description": "🐛 Routes de debug et diagnostics"}, ] class TypeDocument(int, Enum): DEVIS = settings.SAGE_TYPE_DEVIS BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE PREPARATION = settings.SAGE_TYPE_PREPARATION BON_LIVRAISON = settings.SAGE_TYPE_BON_LIVRAISON BON_RETOUR = settings.SAGE_TYPE_BON_RETOUR 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_RETOUR = 4 BON_AVOIR = 5 FACTURE = 6 class StatutSignature(str, Enum): EN_ATTENTE = "EN_ATTENTE" ENVOYE = "ENVOYE" SIGNE = "SIGNE" REFUSE = "REFUSE" EXPIRE = "EXPIRE" class StatutEmail(str, Enum): EN_ATTENTE = "EN_ATTENTE" EN_COURS = "EN_COURS" ENVOYE = "ENVOYE" OUVERT = "OUVERT" ERREUR = "ERREUR" BOUNCE = "BOUNCE" class ClientResponse(BaseModel): """Modèle de réponse client simplifié (pour listes)""" numero: Optional[str] = None intitule: Optional[str] = None adresse: Optional[str] = None code_postal: Optional[str] = None ville: Optional[str] = None email: Optional[str] = None telephone: Optional[str] = None # Téléphone principal (fixe ou mobile) class Contact(BaseModel): """ Contact associé à un tiers (client/fournisseur) Tous les champs de F_CONTACTT """ numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)") contact_numero: Optional[int] = Field(None, description="Numéro unique du contact (CT_No)") n_contact: Optional[int] = Field(None, description="Numéro de référence contact (N_Contact)") civilite: Optional[str] = Field(None, description="Civilité : 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)") fonction: Optional[str] = Field(None, description="Fonction/Titre (CT_Fonction)") service_code: Optional[int] = Field(None, description="Code du service (N_Service)") telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") portable: Optional[str] = Field(None, description="Téléphone mobile (CT_TelPortable)") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Adresse email (CT_EMail)") facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") est_defaut: Optional[bool] = Field( False, description="True si ce contact est le contact par défaut du client" ) civilite_map: ClassVar[dict] = { 0: "M.", 1: "Mme", 2: "Mlle", 3: "Société", } @validator("civilite", pre=True, always=True) def convert_civilite(cls, v): if v is None: return v if isinstance(v, int): return cls.civilite_map.get(v, str(v)) return v class Config: json_schema_extra = { "example": { "numero": "CLI000001", "contact_numero": 1, "n_contact": 1, "civilite": "M.", "nom": "Dupont", "prenom": "Jean", "fonction": "Directeur Commercial", "service_code": 1, "telephone": "0123456789", "portable": "0612345678", "telecopie": "0123456788", "email": "j.dupont@exemple.fr", "facebook": "https://facebook.com/jean.dupont", "linkedin": "https://linkedin.com/in/jeandupont", "skype": "jean.dupont.pro" } } class ClientDetails(BaseModel): """ Modèle de réponse client complet (GET /clients/{code}) Strictement aligné avec les champs retournés par lister_tous_clients """ numero: Optional[str] = Field(None, description="Code client (CT_Num)") intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") type_tiers: Optional[int] = Field(None, description="Type : 0=Client, 1=Fournisseur (CT_Type)") qualite: Optional[str] = Field(None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)") classement: Optional[str] = Field(None, description="Code de classement (CT_Classement)") raccourci: Optional[str] = Field(None, description="Code raccourci 7 car. (CT_Raccourci)") siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire (CT_Identifiant)") code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") complement: Optional[str] = Field(None, description="Complément d'adresse (CT_Complement)") code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)") ville: Optional[str] = Field(None, description="Ville (CT_Ville)") region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") pays: Optional[str] = Field(None, description="Pays (CT_Pays)") telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Email principal (CT_EMail)") site_web: Optional[str] = Field(None, description="Site web (CT_Site)") facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") statistique01: Optional[str] = Field(None, description="Statistique 1 (CT_Statistique01)") statistique02: Optional[str] = Field(None, description="Statistique 2 (CT_Statistique02)") statistique03: Optional[str] = Field(None, description="Statistique 3 (CT_Statistique03)") statistique04: Optional[str] = Field(None, description="Statistique 4 (CT_Statistique04)") statistique05: Optional[str] = Field(None, description="Statistique 5 (CT_Statistique05)") statistique06: Optional[str] = Field(None, description="Statistique 6 (CT_Statistique06)") statistique07: Optional[str] = Field(None, description="Statistique 7 (CT_Statistique07)") statistique08: Optional[str] = Field(None, description="Statistique 8 (CT_Statistique08)") statistique09: Optional[str] = Field(None, description="Statistique 9 (CT_Statistique09)") statistique10: Optional[str] = Field(None, description="Statistique 10 (CT_Statistique10)") encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé (CT_Encours)") assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit (CT_Assurance)") langue: Optional[int] = Field(None, description="Code langue 0=FR, 1=EN (CT_Langue)") commercial_code: Optional[int] = Field(None, description="Code du commercial (CO_No)") lettrage_auto: Optional[bool] = Field(None, description="Lettrage automatique (CT_Lettrage)") est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") type_facture: Optional[int] = Field(None, description="Type facture 0=Facture, 1=BL (CT_Facture)") est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") bl_en_facture: Optional[int] = Field(None, description="Imprimer BL en facture (CT_BLFact)") saut_page: Optional[int] = Field(None, description="Saut de page sur documents (CT_Saut)") validation_echeance: Optional[int] = Field(None, description="Valider les échéances (CT_ValidEch)") controle_encours: Optional[int] = Field(None, description="Contrôler l'encours (CT_ControlEnc)") exclure_relance: Optional[bool] = Field(None, description="Exclure des relances (CT_NotRappel)") exclure_penalites: Optional[bool] = Field(None, description="Exclure des pénalités (CT_NotPenal)") bon_a_payer: Optional[int] = Field(None, description="Bon à payer obligatoire (CT_BonAPayer)") priorite_livraison: Optional[int] = Field(None, description="Priorité livraison (CT_PrioriteLivr)") livraison_partielle: Optional[int] = Field(None, description="Livraison partielle (CT_LivrPartielle)") delai_transport: Optional[int] = Field(None, description="Délai transport jours (CT_DelaiTransport)") delai_appro: Optional[int] = Field(None, description="Délai appro jours (CT_DelaiAppro)") commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire)") section_analytique: Optional[str] = Field(None, description="Section analytique (CA_Num)") mode_reglement_code: Optional[int] = Field(None, description="Code mode règlement (MR_No)") surveillance_active: Optional[bool] = Field(None, description="Surveillance financière (CT_Surveillance)") coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)") forme_juridique: Optional[str] = Field(None, description="Forme juridique SA, SARL (CT_SvFormeJuri)") effectif: Optional[str] = Field(None, description="Nombre d'employés (CT_SvEffectif)") sv_regularite: Optional[str] = Field(None, description="Régularité paiements (CT_SvRegul)") sv_cotation: Optional[str] = Field(None, description="Cotation crédit (CT_SvCotation)") sv_objet_maj: Optional[str] = Field(None, description="Objet dernière MAJ (CT_SvObjetMaj)") sv_chiffre_affaires: Optional[float] = Field(None, description="Chiffre d'affaires (CT_SvCA)") sv_resultat: Optional[float] = Field(None, description="Résultat financier (CT_SvResultat)") compte_general: Optional[str] = Field(None, description="Compte général principal (CG_NumPrinc)") categorie_tarif: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") categorie_compta: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") contacts: Optional[List[Contact]] = Field( default_factory=list, description="Liste des contacts du client" ) class Config: json_schema_extra = { "example": { "numero": "CLI000001", "intitule": "SARL EXEMPLE", "type_tiers": 0, "qualite": "CLI", "classement": "A", "raccourci": "EXEMPL", "siret": "12345678901234", "tva_intra": "FR12345678901", "code_naf": "6201Z", "contact": "Jean Dupont", "adresse": "123 Rue de la Paix", "complement": "Bâtiment B", "code_postal": "75001", "ville": "Paris", "region": "Île-de-France", "pays": "France", "telephone": "0123456789", "telecopie": "0123456788", "email": "contact@exemple.fr", "site_web": "https://www.exemple.fr", "facebook": "https://facebook.com/exemple", "linkedin": "https://linkedin.com/company/exemple", "taux01": 0.0, "taux02": 0.0, "taux03": 0.0, "taux04": 0.0, "statistique01": "Informatique", "statistique02": "", "statistique03": "", "statistique04": "", "statistique05": "", "statistique06": "", "statistique07": "", "statistique08": "", "statistique09": "", "statistique10": "", "encours_autorise": 50000.0, "assurance_credit": 40000.0, "langue": 0, "commercial_code": 1, "lettrage_auto": True, "est_actif": True, "type_facture": 1, "est_prospect": False, "bl_en_facture": 0, "saut_page": 0, "validation_echeance": 0, "controle_encours": 1, "exclure_relance": False, "exclure_penalites": False, "bon_a_payer": 0, "priorite_livraison": 1, "livraison_partielle": 1, "delai_transport": 2, "delai_appro": 0, "commentaire": "Client important", "section_analytique": "", "mode_reglement_code": 1, "surveillance_active": True, "coface": "COF12345", "forme_juridique": "SARL", "effectif": "50-99", "sv_regularite": "", "sv_cotation": "", "sv_objet_maj": "", "sv_chiffre_affaires": 2500000.0, "sv_resultat": 150000.0, "compte_general": "4110000", "categorie_tarif": 0, "categorie_compta": 0, } } class FournisseurDetails(BaseModel): """ Modèle de réponse fournisseur complet (GET /fournisseurs/{code}) Strictement aligné avec ClientDetails """ numero: Optional[str] = Field(None, description="Code fournisseur (CT_Num)") intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)") type_tiers: Optional[int] = Field(None, description="Type : 0=Client, 1=Fournisseur (CT_Type)") qualite: Optional[str] = Field(None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)") classement: Optional[str] = Field(None, description="Code de classement (CT_Classement)") raccourci: Optional[str] = Field(None, description="Code raccourci 7 car. (CT_Raccourci)") siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire (CT_Identifiant)") code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)") adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") complement: Optional[str] = Field(None, description="Complément d'adresse (CT_Complement)") code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)") ville: Optional[str] = Field(None, description="Ville (CT_Ville)") region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") pays: Optional[str] = Field(None, description="Pays (CT_Pays)") telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Email principal (CT_EMail)") site_web: Optional[str] = Field(None, description="Site web (CT_Site)") facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") statistique01: Optional[str] = Field(None, description="Statistique 1 (CT_Statistique01)") statistique02: Optional[str] = Field(None, description="Statistique 2 (CT_Statistique02)") statistique03: Optional[str] = Field(None, description="Statistique 3 (CT_Statistique03)") statistique04: Optional[str] = Field(None, description="Statistique 4 (CT_Statistique04)") statistique05: Optional[str] = Field(None, description="Statistique 5 (CT_Statistique05)") statistique06: Optional[str] = Field(None, description="Statistique 6 (CT_Statistique06)") statistique07: Optional[str] = Field(None, description="Statistique 7 (CT_Statistique07)") statistique08: Optional[str] = Field(None, description="Statistique 8 (CT_Statistique08)") statistique09: Optional[str] = Field(None, description="Statistique 9 (CT_Statistique09)") statistique10: Optional[str] = Field(None, description="Statistique 10 (CT_Statistique10)") encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé (CT_Encours)") assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit (CT_Assurance)") langue: Optional[int] = Field(None, description="Code langue 0=FR, 1=EN (CT_Langue)") commercial_code: Optional[int] = Field(None, description="Code du commercial (CO_No)") lettrage_auto: Optional[bool] = Field(None, description="Lettrage automatique (CT_Lettrage)") est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") type_facture: Optional[int] = Field(None, description="Type facture 0=Facture, 1=BL (CT_Facture)") est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)") bl_en_facture: Optional[int] = Field(None, description="Imprimer BL en facture (CT_BLFact)") saut_page: Optional[int] = Field(None, description="Saut de page sur documents (CT_Saut)") validation_echeance: Optional[int] = Field(None, description="Valider les échéances (CT_ValidEch)") controle_encours: Optional[int] = Field(None, description="Contrôler l'encours (CT_ControlEnc)") exclure_relance: Optional[bool] = Field(None, description="Exclure des relances (CT_NotRappel)") exclure_penalites: Optional[bool] = Field(None, description="Exclure des pénalités (CT_NotPenal)") bon_a_payer: Optional[int] = Field(None, description="Bon à payer obligatoire (CT_BonAPayer)") priorite_livraison: Optional[int] = Field(None, description="Priorité livraison (CT_PrioriteLivr)") livraison_partielle: Optional[int] = Field(None, description="Livraison partielle (CT_LivrPartielle)") delai_transport: Optional[int] = Field(None, description="Délai transport jours (CT_DelaiTransport)") delai_appro: Optional[int] = Field(None, description="Délai appro jours (CT_DelaiAppro)") commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire)") section_analytique: Optional[str] = Field(None, description="Section analytique (CA_Num)") mode_reglement_code: Optional[int] = Field(None, description="Code mode règlement (MR_No)") surveillance_active: Optional[bool] = Field(None, description="Surveillance financière (CT_Surveillance)") coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)") forme_juridique: Optional[str] = Field(None, description="Forme juridique SA, SARL (CT_SvFormeJuri)") effectif: Optional[str] = Field(None, description="Nombre d'employés (CT_SvEffectif)") sv_regularite: Optional[str] = Field(None, description="Régularité paiements (CT_SvRegul)") sv_cotation: Optional[str] = Field(None, description="Cotation crédit (CT_SvCotation)") sv_objet_maj: Optional[str] = Field(None, description="Objet dernière MAJ (CT_SvObjetMaj)") sv_chiffre_affaires: Optional[float] = Field(None, description="Chiffre d'affaires (CT_SvCA)") sv_resultat: Optional[float] = Field(None, description="Résultat financier (CT_SvResultat)") compte_general: Optional[str] = Field(None, description="Compte général principal (CG_NumPrinc)") categorie_tarif: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)") categorie_compta: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)") contacts: Optional[List[Contact]] = Field( default_factory=list, description="Liste des contacts du fournisseur" ) class Config: json_schema_extra = { "example": { "numero": "FOU000001", "intitule": "SARL FOURNISSEUR EXEMPLE", "type_tiers": 1, "qualite": "FOU", "classement": "A", "raccourci": "EXEMPL", "siret": "12345678901234", "tva_intra": "FR12345678901", "code_naf": "6201Z", "contact": "Jean Dupont", "adresse": "123 Rue de la Paix", "complement": "Bâtiment B", "code_postal": "75001", "ville": "Paris", "region": "Île-de-France", "pays": "France", "telephone": "0123456789", "telecopie": "0123456788", "email": "contact@exemple.fr", "site_web": "https://www.exemple.fr", "facebook": "https://facebook.com/exemple", "linkedin": "https://linkedin.com/company/exemple", "taux01": 0.0, "taux02": 0.0, "taux03": 0.0, "taux04": 0.0, "statistique01": "Informatique", "statistique02": "", "statistique03": "", "statistique04": "", "statistique05": "", "statistique06": "", "statistique07": "", "statistique08": "", "statistique09": "", "statistique10": "", "encours_autorise": 50000.0, "assurance_credit": 40000.0, "langue": 0, "commercial_code": 1, "lettrage_auto": True, "est_actif": True, "type_facture": 1, "est_prospect": False, "bl_en_facture": 0, "saut_page": 0, "validation_echeance": 0, "controle_encours": 1, "exclure_relance": False, "exclure_penalites": False, "bon_a_payer": 0, "priorite_livraison": 1, "livraison_partielle": 1, "delai_transport": 2, "delai_appro": 0, "commentaire": "Client important", "section_analytique": "", "mode_reglement_code": 1, "surveillance_active": True, "coface": "COF12345", "forme_juridique": "SARL", "effectif": "50-99", "sv_regularite": "", "sv_cotation": "", "sv_objet_maj": "", "sv_chiffre_affaires": 2500000.0, "sv_resultat": 150000.0, "compte_general": "4110000", "categorie_tarif": 0, "categorie_compta": 0, } } class EmplacementStockModel(BaseModel): """Détail du stock dans un emplacement spécifique""" depot: str = Field(..., description="Numéro du dépôt (DE_No)") emplacement: str = Field(..., description="Code emplacement (DP_No)") qte_stockee: float = Field(0.0, description="Quantité stockée (AE_QteSto)") qte_preparee: float = Field(0.0, description="Quantité préparée (AE_QtePrepa)") qte_a_controler: float = Field(0.0, description="Quantité à contrôler (AE_QteAControler)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") depot_num: Optional[str] = Field(None, description="Numéro dépôt") depot_nom: Optional[str] = Field(None, description="Nom du dépôt (DE_Intitule)") depot_code: Optional[str] = Field(None, description="Code dépôt (DE_Code)") depot_adresse: Optional[str] = Field(None, description="Adresse (DE_Adresse)") depot_complement: Optional[str] = Field(None, description="Complément adresse") depot_code_postal: Optional[str] = Field(None, description="Code postal") depot_ville: Optional[str] = Field(None, description="Ville") depot_contact: Optional[str] = Field(None, description="Contact") depot_est_principal: Optional[bool] = Field(None, description="Dépôt principal (DE_Principal)") depot_categorie_compta: Optional[int] = Field(None, description="Catégorie comptable") depot_region: Optional[str] = Field(None, description="Région") depot_pays: Optional[str] = Field(None, description="Pays") depot_email: Optional[str] = Field(None, description="Email") depot_telephone: Optional[str] = Field(None, description="Téléphone") depot_fax: Optional[str] = Field(None, description="Fax") depot_emplacement_defaut: Optional[str] = Field(None, description="Emplacement par défaut") depot_exclu: Optional[bool] = Field(None, description="Dépôt exclu") emplacement_code: Optional[str] = Field(None, description="Code emplacement (DP_Code)") emplacement_libelle: Optional[str] = Field(None, description="Libellé emplacement (DP_Intitule)") emplacement_zone: Optional[str] = Field(None, description="Zone (DP_Zone)") emplacement_type: Optional[int] = Field(None, description="Type emplacement (DP_Type)") class Config: json_schema_extra = { "example": { "depot": "01", "emplacement": "A1-01", "qte_stockee": 100.0, "qte_preparee": 5.0, "depot_nom": "Dépôt principal", "depot_ville": "Paris", "emplacement_libelle": "Allée A, Niveau 1, Case 01", "emplacement_zone": "Zone A" } } class GammeArticleModel(BaseModel): """Gamme d'un article (taille, couleur, etc.)""" numero_gamme: int = Field(..., description="Numéro de gamme (AG_No)") enumere: str = Field(..., description="Code énuméré (EG_Enumere)") type_gamme: int = Field(0, description="Type de gamme (AG_Type)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") ligne: Optional[int] = Field(None, description="Ligne énuméré (EG_Ligne)") borne_sup: Optional[float] = Field(None, description="Borne supérieure (EG_BorneSup)") gamme_nom: Optional[str] = Field(None, description="Nom de la gamme (P_GAMME.G_Intitule)") class Config: json_schema_extra = { "example": { "numero_gamme": 1, "enumere": "001", "type_gamme": 0, "ligne": 1, "gamme_nom": "Taille" } } class TarifClientModel(BaseModel): """Tarif spécifique pour un client ou catégorie tarifaire""" categorie: int = Field(..., description="Catégorie tarifaire (AC_Categorie)") client_num: Optional[str] = Field(None, description="Numéro client (CT_Num)") prix_vente: float = Field(0.0, description="Prix de vente HT (AC_PrixVen)") coefficient: float = Field(0.0, description="Coefficient (AC_Coef)") prix_ttc: float = Field(0.0, description="Prix TTC (AC_PrixTTC)") arrondi: float = Field(0.0, description="Arrondi (AC_Arrondi)") qte_montant: float = Field(0.0, description="Quantité montant (AC_QteMont)") enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)") prix_devise: float = Field(0.0, description="Prix en devise (AC_PrixDev)") devise: int = Field(0, description="Code devise (AC_Devise)") remise: float = Field(0.0, description="Remise (AC_Remise)") mode_calcul: int = Field(0, description="Mode de calcul (AC_Calcul)") type_remise: int = Field(0, description="Type de remise (AC_TypeRem)") ref_client: Optional[str] = Field(None, description="Référence client (AC_RefClient)") coef_nouveau: float = Field(0.0, description="Nouveau coefficient (AC_CoefNouv)") prix_vente_nouveau: float = Field(0.0, description="Nouveau prix vente (AC_PrixVenNouv)") prix_devise_nouveau: float = Field(0.0, description="Nouveau prix devise (AC_PrixDevNouv)") remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AC_RemiseNouv)") date_application: Optional[datetime] = Field(None, description="Date application (AC_DateApplication)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") class Config: json_schema_extra = { "example": { "categorie": 1, "client_num": "CLI001", "prix_vente": 110.00, "coefficient": 1.294, "remise": 12.0 } } class ComposantModel(BaseModel): """Composant/Opération de nomenclature""" operation: str = Field(..., description="Code opération (AT_Operation)") code_ressource: Optional[str] = Field(None, description="Code ressource (RP_Code)") temps: float = Field(0.0, description="Temps nécessaire (AT_Temps)") type: int = Field(0, description="Type composant (AT_Type)") description: Optional[str] = Field(None, description="Description (AT_Description)") ordre: int = Field(0, description="Ordre d'exécution (AT_Ordre)") gamme_1_comp: int = Field(0, description="Gamme 1 composant (AG_No1Comp)") gamme_2_comp: int = Field(0, description="Gamme 2 composant (AG_No2Comp)") type_ressource: int = Field(0, description="Type ressource (AT_TypeRessource)") chevauche: int = Field(0, description="Chevauchement (AT_Chevauche)") demarre: int = Field(0, description="Démarrage (AT_Demarre)") operation_chevauche: Optional[str] = Field(None, description="Opération chevauchée (AT_OperationChevauche)") valeur_chevauche: float = Field(0.0, description="Valeur chevauchement (AT_ValeurChevauche)") type_chevauche: int = Field(0, description="Type chevauchement (AT_TypeChevauche)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") class Config: json_schema_extra = { "example": { "operation": "OP010", "code_ressource": "RES01", "temps": 15.5, "description": "Montage pièce A", "ordre": 10 } } class ComptaArticleModel(BaseModel): """Comptabilité spécifique d'un article""" champ: int = Field(..., description="Champ (ACP_Champ)") compte_general: Optional[str] = Field(None, description="Compte général (ACP_ComptaCPT_CompteG)") compte_auxiliaire: Optional[str] = Field(None, description="Compte auxiliaire (ACP_ComptaCPT_CompteA)") taxe_1: Optional[str] = Field(None, description="Taxe 1 (ACP_ComptaCPT_Taxe1)") taxe_2: Optional[str] = Field(None, description="Taxe 2 (ACP_ComptaCPT_Taxe2)") taxe_3: Optional[str] = Field(None, description="Taxe 3 (ACP_ComptaCPT_Taxe3)") taxe_date_1: Optional[datetime] = Field(None, description="Date taxe 1") taxe_date_2: Optional[datetime] = Field(None, description="Date taxe 2") taxe_date_3: Optional[datetime] = Field(None, description="Date taxe 3") taxe_anc_1: Optional[str] = Field(None, description="Ancienne taxe 1") taxe_anc_2: Optional[str] = Field(None, description="Ancienne taxe 2") taxe_anc_3: Optional[str] = Field(None, description="Ancienne taxe 3") type_facture: int = Field(0, description="Type de facture (ACP_TypeFacture)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") class Config: json_schema_extra = { "example": { "champ": 1, "compte_general": "707100", "taxe_1": "TVA20", "type_facture": 0 } } class FournisseurArticleModel(BaseModel): """Fournisseur d'un article""" fournisseur_num: str = Field(..., description="Numéro fournisseur (CT_Num)") ref_fournisseur: Optional[str] = Field(None, description="Référence fournisseur (AF_RefFourniss)") prix_achat: float = Field(0.0, description="Prix d'achat (AF_PrixAch)") unite: Optional[str] = Field(None, description="Unité (AF_Unite)") conversion: float = Field(0.0, description="Conversion (AF_Conversion)") delai_appro: int = Field(0, description="Délai approvisionnement (AF_DelaiAppro)") garantie: int = Field(0, description="Garantie (AF_Garantie)") colisage: int = Field(0, description="Colisage (AF_Colisage)") qte_mini: float = Field(0.0, description="Quantité minimum (AF_QteMini)") qte_montant: float = Field(0.0, description="Quantité montant (AF_QteMont)") enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)") est_principal: bool = Field(False, description="Fournisseur principal (AF_Principal)") prix_devise: float = Field(0.0, description="Prix devise (AF_PrixDev)") devise: int = Field(0, description="Code devise (AF_Devise)") remise: float = Field(0.0, description="Remise (AF_Remise)") conversion_devise: float = Field(0.0, description="Conversion devise (AF_ConvDiv)") type_remise: int = Field(0, description="Type remise (AF_TypeRem)") code_barre_fournisseur: Optional[str] = Field(None, description="Code-barres fournisseur (AF_CodeBarre)") prix_achat_nouveau: float = Field(0.0, description="Nouveau prix achat (AF_PrixAchNouv)") prix_devise_nouveau: float = Field(0.0, description="Nouveau prix devise (AF_PrixDevNouv)") remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AF_RemiseNouv)") date_application: Optional[datetime] = Field(None, description="Date application (AF_DateApplication)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") class Config: json_schema_extra = { "example": { "fournisseur_num": "F001", "ref_fournisseur": "REF-FOURN-001", "prix_achat": 85.00, "delai_appro": 15, "est_principal": True } } class ReferenceEnumereeModel(BaseModel): """Référence énumérée (article avec gammes)""" gamme_1: int = Field(0, description="Gamme 1 (AG_No1)") gamme_2: int = Field(0, description="Gamme 2 (AG_No2)") reference_enumeree: str = Field(..., description="Référence énumérée (AE_Ref)") prix_achat: float = Field(0.0, description="Prix achat (AE_PrixAch)") code_barre: Optional[str] = Field(None, description="Code-barres (AE_CodeBarre)") prix_achat_nouveau: float = Field(0.0, description="Nouveau prix achat (AE_PrixAchNouv)") edi_code: Optional[str] = Field(None, description="Code EDI (AE_EdiCode)") en_sommeil: bool = Field(False, description="En sommeil (AE_Sommeil)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") class Config: json_schema_extra = { "example": { "gamme_1": 1, "gamme_2": 3, "reference_enumeree": "ART001-T1-C3", "prix_achat": 85.00 } } class MediaArticleModel(BaseModel): """Média attaché à un article (photo, document, etc.)""" commentaire: Optional[str] = Field(None, description="Commentaire (ME_Commentaire)") fichier: Optional[str] = Field(None, description="Nom fichier (ME_Fichier)") type_mime: Optional[str] = Field(None, description="Type MIME (ME_TypeMIME)") origine: int = Field(0, description="Origine (ME_Origine)") ged_id: Optional[str] = Field(None, description="ID GED (ME_GedId)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") class Config: json_schema_extra = { "example": { "commentaire": "Photo produit principale", "fichier": "ART001_photo1.jpg", "type_mime": "image/jpeg" } } class PrixGammeModel(BaseModel): """Prix spécifique par combinaison de gammes""" gamme_1: int = Field(0, description="Gamme 1 (AG_No1)") gamme_2: int = Field(0, description="Gamme 2 (AG_No2)") prix_net: float = Field(0.0, description="Prix net (AR_PUNet)") cout_standard: float = Field(0.0, description="Coût standard (AR_CoutStd)") date_creation: Optional[datetime] = Field(None, description="Date création") date_modification: Optional[datetime] = Field(None, description="Date modification") class Config: json_schema_extra = { "example": { "gamme_1": 1, "gamme_2": 3, "prix_net": 125.50, "cout_standard": 82.30 } } class ArticleResponse(BaseModel): """Article complet avec tous les enrichissements disponibles""" reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres principal (AR_CodeBarre)") code_barre: Optional[str] = Field(None, description="Code-barres (alias de code_ean)") edi_code: Optional[str] = Field(None, description="Code EDI (AR_EdiCode)") raccourci: Optional[str] = Field(None, description="Code raccourci (AR_Raccourci)") prix_vente: float = Field(..., description="Prix de vente HT unitaire (AR_PrixVen)") prix_achat: Optional[float] = Field(None, description="Prix d'achat HT (AR_PrixAch)") coef: Optional[float] = Field(None, description="Coefficient multiplicateur (AR_Coef)") prix_net: Optional[float] = Field(None, description="Prix unitaire net (AR_PUNet)") prix_achat_nouveau: Optional[float] = Field(None, description="Nouveau prix d'achat à venir (AR_PrixAchNouv)") coef_nouveau: Optional[float] = Field(None, description="Nouveau coefficient à venir (AR_CoefNouv)") prix_vente_nouveau: Optional[float] = Field(None, description="Nouveau prix de vente à venir (AR_PrixVenNouv)") date_application_prix: Optional[str] = Field(None, description="Date d'application des nouveaux prix (AR_DateApplication)") cout_standard: Optional[float] = Field(None, description="Coût standard (AR_CoutStd)") stock_reel: float = Field(default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)") stock_mini: Optional[float] = Field(None, description="Stock minimum (F_ARTSTOCK.AS_QteMini)") stock_maxi: Optional[float] = Field(None, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)") stock_reserve: Optional[float] = Field(None, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)") stock_commande: Optional[float] = Field(None, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)") stock_disponible: Optional[float] = Field(None, description="Stock disponible = réel - réservé") emplacements: List[EmplacementStockModel] = Field( default_factory=list, description="Détail du stock par emplacement (F_ARTSTOCKEMPL + F_DEPOT + F_DEPOTEMPL)" ) nb_emplacements: int = Field(0, description="Nombre d'emplacements") suivi_stock: Optional[bool] = Field(None, description="Suivi de stock activé (AR_SuiviStock)") nomenclature: Optional[bool] = Field(None, description="Article avec nomenclature (AR_Nomencl)") qte_composant: Optional[float] = Field(None, description="Quantité de composant (AR_QteComp)") qte_operatoire: Optional[float] = Field(None, description="Quantité opératoire (AR_QteOperatoire)") unite_vente: Optional[str] = Field(None, max_length=10, description="Unité de vente (AR_UniteVen)") unite_poids: Optional[str] = Field(None, max_length=10, description="Unité de poids (AR_UnitePoids)") poids_net: Optional[float] = Field(None, description="Poids net unitaire en kg (AR_PoidsNet)") poids_brut: Optional[float] = Field(None, description="Poids brut unitaire en kg (AR_PoidsBrut)") gamme_1: Optional[str] = Field(None, description="Énumération gamme 1 (AR_Gamme1)") gamme_2: Optional[str] = Field(None, description="Énumération gamme 2 (AR_Gamme2)") gammes: List[GammeArticleModel] = Field( default_factory=list, description="Détail des gammes (F_ARTGAMME + F_ENUMGAMME + P_GAMME)" ) nb_gammes: int = Field(0, description="Nombre de gammes") tarifs_clients: List[TarifClientModel] = Field( default_factory=list, description="Tarifs spécifiques par client/catégorie (F_ARTCLIENT)" ) nb_tarifs_clients: int = Field(0, description="Nombre de tarifs clients") composants: List[ComposantModel] = Field( default_factory=list, description="Composants/Opérations de production (F_ARTCOMPO)" ) nb_composants: int = Field(0, description="Nombre de composants") compta_vente: List[ComptaArticleModel] = Field( default_factory=list, description="Comptabilité vente (F_ARTCOMPTA type 0)" ) compta_achat: List[ComptaArticleModel] = Field( default_factory=list, description="Comptabilité achat (F_ARTCOMPTA type 1)" ) compta_stock: List[ComptaArticleModel] = Field( default_factory=list, description="Comptabilité stock (F_ARTCOMPTA type 2)" ) fournisseurs: List[FournisseurArticleModel] = Field( default_factory=list, description="Tous les fournisseurs de l'article (F_ARTFOURNISS)" ) nb_fournisseurs: int = Field(0, description="Nombre de fournisseurs") refs_enumerees: List[ReferenceEnumereeModel] = Field( default_factory=list, description="Références énumérées (F_ARTENUMREF)" ) nb_refs_enumerees: int = Field(0, description="Nombre de références énumérées") medias: List[MediaArticleModel] = Field( default_factory=list, description="Médias attachés (F_ARTICLEMEDIA)" ) nb_medias: int = Field(0, description="Nombre de médias") prix_gammes: List[PrixGammeModel] = Field( default_factory=list, description="Prix par combinaison de gammes (F_ARTPRIX)" ) nb_prix_gammes: int = Field(0, description="Nombre de prix par gammes") type_article: Optional[int] = Field( None, ge=0, le=3, description="Type : 0=Article, 1=Prestation, 2=Divers, 3=Nomenclature (AR_Type)" ) type_article_libelle: Optional[str] = Field(None, description="Libellé du type d'article") famille_code: Optional[str] = Field(None, max_length=20, description="Code famille (FA_CodeFamille)") famille_libelle: Optional[str] = Field(None, description="Libellé de la famille (F_FAMILLE.FA_Intitule)") famille_type: Optional[int] = Field(None, description="Type de famille : 0=Détail, 1=Total (F_FAMILLE.FA_Type)") famille_unite_vente: Optional[str] = Field(None, description="Unité de vente de la famille (F_FAMILLE.FA_UniteVen)") famille_coef: Optional[float] = Field(None, description="Coefficient de la famille (F_FAMILLE.FA_Coef)") famille_suivi_stock: Optional[bool] = Field(None, description="Suivi stock de la famille (F_FAMILLE.FA_SuiviStock)") famille_garantie: Optional[int] = Field(None, description="Garantie de la famille (F_FAMILLE.FA_Garantie)") famille_unite_poids: Optional[str] = Field(None, description="Unité de poids de la famille (F_FAMILLE.FA_UnitePoids)") famille_delai: Optional[int] = Field(None, description="Délai de la famille (F_FAMILLE.FA_Delai)") famille_nb_colis: Optional[int] = Field(None, description="Nombre de colis de la famille (F_FAMILLE.FA_NbColis)") famille_code_fiscal: Optional[str] = Field(None, description="Code fiscal de la famille (F_FAMILLE.FA_CodeFiscal)") famille_escompte: Optional[bool] = Field(None, description="Escompte de la famille (F_FAMILLE.FA_Escompte)") famille_centrale: Optional[bool] = Field(None, description="Famille centrale (F_FAMILLE.FA_Central)") famille_nature: Optional[int] = Field(None, description="Nature de la famille (F_FAMILLE.FA_Nature)") famille_hors_stat: Optional[bool] = Field(None, description="Hors statistique famille (F_FAMILLE.FA_HorsStat)") famille_pays: Optional[str] = Field(None, description="Pays de la famille (F_FAMILLE.FA_Pays)") nature: Optional[int] = Field(None, description="Nature de l'article (AR_Nature)") garantie: Optional[int] = Field(None, description="Durée de garantie en mois (AR_Garantie)") code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)") pays: Optional[str] = Field(None, description="Pays d'origine (AR_Pays)") fournisseur_principal: Optional[int] = Field(None, description="N° compte du fournisseur principal (CO_No → F_COMPTET.CT_Num)") fournisseur_nom: Optional[str] = Field(None, description="Nom du fournisseur principal (F_COMPTET.CT_Intitule)") conditionnement: Optional[str] = Field(None, description="Conditionnement d'achat (AR_Condition)") conditionnement_qte: Optional[float] = Field(None, description="Quantité conditionnement (F_ENUMCOND.EC_Quantite)") conditionnement_edi: Optional[str] = Field(None, description="Code EDI conditionnement (F_ENUMCOND.EC_EdiCode)") nb_colis: Optional[int] = Field(None, description="Nombre de colis par unité (AR_NbColis)") prevision: Optional[bool] = Field(None, description="Gestion en prévision (AR_Prevision)") est_actif: bool = Field(default=True, description="Article actif (AR_Sommeil = 0)") en_sommeil: bool = Field(default=False, description="Article en sommeil (AR_Sommeil = 1)") article_substitut: Optional[str] = Field(None, description="Référence article de substitution (AR_Substitut)") soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte (AR_Escompte)") delai: Optional[int] = Field(None, description="Délai de livraison en jours (AR_Delai)") publie: Optional[bool] = Field(None, description="Publié sur web/catalogue (AR_Publie)") hors_statistique: Optional[bool] = Field(None, description="Exclus des statistiques (AR_HorsStat)") vente_debit: Optional[bool] = Field(None, description="Vente au débit (AR_VteDebit)") non_imprimable: Optional[bool] = Field(None, description="Non imprimable sur documents (AR_NotImp)") transfere: Optional[bool] = Field(None, description="Article transféré (AR_Transfere)") contremarque: Optional[bool] = Field(None, description="Article en contremarque (AR_Contremarque)") fact_poids: Optional[bool] = Field(None, description="Facturation au poids (AR_FactPoids)") fact_forfait: Optional[bool] = Field(None, description="Facturation au forfait (AR_FactForfait)") saisie_variable: Optional[bool] = Field(None, description="Saisie variable (AR_SaisieVar)") fictif: Optional[bool] = Field(None, description="Article fictif (AR_Fictif)") sous_traitance: Optional[bool] = Field(None, description="Article en sous-traitance (AR_SousTraitance)") criticite: Optional[int] = Field(None, description="Niveau de criticité (AR_Criticite)") reprise_code_defaut: Optional[str] = Field(None, description="Code reprise par défaut (RP_CodeDefaut)") delai_fabrication: Optional[int] = Field(None, description="Délai de fabrication (AR_DelaiFabrication)") delai_peremption: Optional[int] = Field(None, description="Délai de péremption (AR_DelaiPeremption)") delai_securite: Optional[int] = Field(None, description="Délai de sécurité (AR_DelaiSecurite)") type_lancement: Optional[int] = Field(None, description="Type de lancement production (AR_TypeLancement)") cycle: Optional[int] = Field(None, description="Cycle de production (AR_Cycle)") photo: Optional[str] = Field(None, description="Chemin/nom du fichier photo (AR_Photo)") langue_1: Optional[str] = Field(None, description="Texte en langue 1 (AR_Langue1)") langue_2: Optional[str] = Field(None, description="Texte en langue 2 (AR_Langue2)") frais_01_denomination: Optional[str] = Field(None, description="Dénomination frais 1 (AR_Frais01FR_Denomination)") frais_02_denomination: Optional[str] = Field(None, description="Dénomination frais 2 (AR_Frais02FR_Denomination)") frais_03_denomination: Optional[str] = Field(None, description="Dénomination frais 3 (AR_Frais03FR_Denomination)") tva_code: Optional[str] = Field(None, description="Code TVA (F_TAXE.TA_Code)") tva_taux: Optional[float] = Field(None, description="Taux de TVA en % (F_TAXE.TA_Taux)") stat_01: Optional[str] = Field(None, description="Statistique 1 (AR_Stat01)") stat_02: Optional[str] = Field(None, description="Statistique 2 (AR_Stat02)") stat_03: Optional[str] = Field(None, description="Statistique 3 (AR_Stat03)") stat_04: Optional[str] = Field(None, description="Statistique 4 (AR_Stat04)") stat_05: Optional[str] = Field(None, description="Statistique 5 (AR_Stat05)") categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)") categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)") categorie_3: Optional[int] = Field(None, description="Catégorie comptable 3 (CL_No3)") categorie_4: Optional[int] = Field(None, description="Catégorie comptable 4 (CL_No4)") date_modification: Optional[str] = Field(None, description="Date de dernière modification (AR_DateModif)") marque_commerciale: Optional[str] = Field(None, description="Marque commerciale (champ personnalisé)") objectif_qtes_vendues: Optional[str] = Field(None, description="Objectif / Quantités vendues (champ personnalisé)") pourcentage_or: Optional[str] = Field(None, description="Pourcentage teneur en or (champ personnalisé)") premiere_commercialisation: Optional[str] = Field(None, description="Date de 1ère commercialisation (champ personnalisé)") interdire_commande: Optional[bool] = Field(None, description="Interdire la commande (champ personnalisé)") exclure: Optional[bool] = Field(None, description="Exclure de certains traitements (champ personnalisé)") class Config: json_schema_extra = { "example": { "reference": "BAGUE-001", "designation": "Bague Or 18K Diamant", "prix_vente": 1299.00, "stock_reel": 15.0, "stock_disponible": 13.0, "nb_emplacements": 2, "nb_gammes": 2, "nb_tarifs_clients": 3, "nb_fournisseurs": 2, "nb_medias": 2, "emplacements": [ { "depot": "01", "emplacement": "A1-01", "qte_stockee": 10.0, "depot_nom": "Dépôt principal" } ], "gammes": [ { "numero_gamme": 1, "enumere": "001", "gamme_nom": "Taille" } ] } } class ArticleListResponse(BaseModel): """Réponse pour une liste d'articles""" total: int = Field(..., description="Nombre total d'articles") articles: List[ArticleResponse] = Field(..., description="Liste des articles") filtre_applique: Optional[str] = Field(None, description="Filtre de recherche appliqué") avec_stock: bool = Field(True, description="Indique si les stocks ont été chargés") avec_famille: bool = Field(True, description="Indique si les familles ont été enrichies") avec_enrichissements_complets: bool = Field( False, description="Indique si tous les enrichissements sont activés" ) class Config: json_schema_extra = { "example": { "total": 1250, "filtre_applique": "bague", "avec_stock": True, "avec_famille": True, "avec_enrichissements_complets": True, "articles": [] } } class LigneDevis(BaseModel): article_code: str quantite: float remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() class DevisRequest(BaseModel): client_id: str date_devis: Optional[date] = None date_livraison: Optional[date] = None reference: Optional[str] = None lignes: List[LigneDevis] class DevisResponse(BaseModel): id: str client_id: str date_devis: str montant_total_ht: float montant_total_ttc: float nb_lignes: int class SignatureRequest(BaseModel): doc_id: str type_doc: TypeDocument email_signataire: EmailStr nom_signataire: str class EmailEnvoiRequest(BaseModel): destinataire: EmailStr cc: Optional[List[EmailStr]] = [] cci: Optional[List[EmailStr]] = [] sujet: str corps_html: str document_ids: Optional[List[str]] = None type_document: Optional[TypeDocument] = None class RelanceDevisRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None class BaremeRemiseResponse(BaseModel): client_id: str remise_max_autorisee: float remise_demandee: float autorisee: bool message: str class TypeTiers(IntEnum): """CT_Type - Type de tiers""" CLIENT = 0 FOURNISSEUR = 1 SALARIE = 2 AUTRE = 3 class ClientCreateRequest(BaseModel): intitule: str = Field( ..., max_length=69, description="Nom du client (CT_Intitule) - OBLIGATOIRE" ) numero: str = Field( ..., max_length=17, description="Numéro client CT_Num (auto si None)" ) type_tiers: int = Field( 0, ge=0, le=3, description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre" ) qualite: Optional[str] = Field( "CLI", max_length=17, description="CT_Qualite: CLI/FOU/SAL/DIV/AUT" ) classement: Optional[str] = Field( None, max_length=17, description="CT_Classement" ) raccourci: Optional[str] = Field( None, max_length=7, description="CT_Raccourci (7 chars max, unique)" ) siret: Optional[str] = Field( None, max_length=15, description="CT_Siret (14-15 chars)" ) tva_intra: Optional[str] = Field( None, max_length=25, description="CT_Identifiant (TVA intracommunautaire)" ) code_naf: Optional[str] = Field( None, max_length=7, description="CT_Ape (Code NAF/APE)" ) contact: Optional[str] = Field( None, max_length=35, description="CT_Contact (double affectation: client + adresse)" ) adresse: Optional[str] = Field( None, max_length=35, description="Adresse.Adresse" ) complement: Optional[str] = Field( None, max_length=35, description="Adresse.Complement" ) code_postal: Optional[str] = Field( None, max_length=9, description="Adresse.CodePostal" ) ville: Optional[str] = Field( None, max_length=35, description="Adresse.Ville" ) region: Optional[str] = Field( None, max_length=25, description="Adresse.CodeRegion" ) pays: Optional[str] = Field( None, max_length=35, description="Adresse.Pays" ) telephone: Optional[str] = Field( None, max_length=21, description="Telecom.Telephone" ) telecopie: Optional[str] = Field( None, max_length=21, description="Telecom.Telecopie (fax)" ) email: Optional[str] = Field( None, max_length=69, description="Telecom.EMail" ) site_web: Optional[str] = Field( None, max_length=69, description="Telecom.Site" ) portable: Optional[str] = Field( None, max_length=21, description="Telecom.Portable" ) facebook: Optional[str] = Field( None, max_length=69, description="Telecom.Facebook ou CT_Facebook" ) linkedin: Optional[str] = Field( None, max_length=69, description="Telecom.LinkedIn ou CT_LinkedIn" ) compte_general: Optional[str] = Field( None, max_length=13, description="CompteGPrinc (défaut selon type_tiers: 4110000, 4010000, 421, 471)" ) categorie_tarifaire: Optional[str] = Field( None, description="N_CatTarif (ID catégorie tarifaire, défaut '0' ou '1')" ) categorie_comptable: Optional[str] = Field( None, description="N_CatCompta (ID catégorie comptable, défaut '0' ou '1')" ) taux01: Optional[float] = Field(None, description="CT_Taux01") taux02: Optional[float] = Field(None, description="CT_Taux02") taux03: Optional[float] = Field(None, description="CT_Taux03") taux04: Optional[float] = Field(None, description="CT_Taux04") secteur: Optional[str] = Field( None, max_length=21, description="Alias de statistique01 (CT_Statistique01)" ) statistique01: Optional[str] = Field(None, max_length=21, description="CT_Statistique01") statistique02: Optional[str] = Field(None, max_length=21, description="CT_Statistique02") statistique03: Optional[str] = Field(None, max_length=21, description="CT_Statistique03") statistique04: Optional[str] = Field(None, max_length=21, description="CT_Statistique04") statistique05: Optional[str] = Field(None, max_length=21, description="CT_Statistique05") statistique06: Optional[str] = Field(None, max_length=21, description="CT_Statistique06") statistique07: Optional[str] = Field(None, max_length=21, description="CT_Statistique07") statistique08: Optional[str] = Field(None, max_length=21, description="CT_Statistique08") statistique09: Optional[str] = Field(None, max_length=21, description="CT_Statistique09") statistique10: Optional[str] = Field(None, max_length=21, description="CT_Statistique10") encours_autorise: Optional[float] = Field( None, description="CT_Encours (montant max autorisé)" ) assurance_credit: Optional[float] = Field( None, description="CT_Assurance (montant assurance crédit)" ) langue: Optional[int] = Field( None, ge=0, description="CT_Langue (0=Français, 1=Anglais, etc.)" ) commercial_code: Optional[int] = Field( None, description="CO_No (ID du collaborateur commercial)" ) lettrage_auto: Optional[bool] = Field( True, description="CT_Lettrage (1=oui, 0=non)" ) est_actif: Optional[bool] = Field( True, description="Inverse de CT_Sommeil (True=actif, False=en sommeil)" ) type_facture: Optional[int] = Field( 1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée" ) est_prospect: Optional[bool] = Field( False, description="CT_Prospect (1=oui, 0=non)" ) bl_en_facture: Optional[int] = Field( None, ge=0, le=1, description="CT_BLFact (impression BL sur facture)" ) saut_page: Optional[int] = Field( None, ge=0, le=1, description="CT_Saut (saut de page après impression)" ) validation_echeance: Optional[int] = Field( None, ge=0, le=1, description="CT_ValidEch" ) controle_encours: Optional[int] = Field( None, ge=0, le=1, description="CT_ControlEnc" ) exclure_relance: Optional[int] = Field( None, ge=0, le=1, description="CT_NotRappel" ) exclure_penalites: Optional[int] = Field( None, ge=0, le=1, description="CT_NotPenal" ) bon_a_payer: Optional[int] = Field( None, ge=0, le=1, description="CT_BonAPayer" ) priorite_livraison: Optional[int] = Field( None, ge=0, le=5, description="CT_PrioriteLivr" ) livraison_partielle: Optional[int] = Field( None, ge=0, le=1, description="CT_LivrPartielle" ) delai_transport: Optional[int] = Field( None, ge=0, description="CT_DelaiTransport (jours)" ) delai_appro: Optional[int] = Field( None, ge=0, description="CT_DelaiAppro (jours)" ) commentaire: Optional[str] = Field( None, max_length=35, description="CT_Commentaire" ) section_analytique: Optional[str] = Field( None, max_length=13, description="CA_Num" ) mode_reglement_code: Optional[int] = Field( None, description="MR_No (ID du mode de règlement)" ) surveillance_active: Optional[int] = Field( None, ge=0, le=1, description="CT_Surveillance (DOIT être défini AVANT coface)" ) coface: Optional[str] = Field( None, max_length=25, description="CT_Coface (code Coface)" ) forme_juridique: Optional[str] = Field( None, max_length=33, description="CT_SvFormeJuri (SARL, SA, etc.)" ) effectif: Optional[str] = Field( None, max_length=11, description="CT_SvEffectif" ) sv_regularite: Optional[str] = Field( None, max_length=3, description="CT_SvRegul" ) sv_cotation: Optional[str] = Field( None, max_length=5, description="CT_SvCotation" ) sv_objet_maj: Optional[str] = Field( None, max_length=61, description="CT_SvObjetMaj" ) ca_annuel: Optional[float] = Field( None, description="CT_SvCA (Chiffre d'affaires annuel) - alias: sv_chiffre_affaires" ) sv_chiffre_affaires: Optional[float] = Field( None, description="CT_SvCA (alias de ca_annuel)" ) sv_resultat: Optional[float] = Field( None, description="CT_SvResultat" ) @field_validator('siret') @classmethod def validate_siret(cls, v): """Valide et nettoie le SIRET""" if v and v.lower() not in ('none', 'null', ''): cleaned = v.replace(' ', '').replace('-', '') if len(cleaned) not in (14, 15): raise ValueError('Le SIRET doit contenir 14 ou 15 caractères') return cleaned return None @field_validator('email') @classmethod def validate_email(cls, v): """Valide le format email""" if v and v.lower() not in ('none', 'null', ''): v = v.strip() if '@' not in v: raise ValueError('Format email invalide') return v return None @field_validator('raccourci') @classmethod def validate_raccourci(cls, v): """Force le raccourci en majuscules""" if v and v.lower() not in ('none', 'null', ''): return v.upper().strip()[:7] return None @field_validator( 'adresse', 'code_postal', 'ville', 'pays', 'telephone', 'tva_intra', 'contact', 'complement', mode='before' ) @classmethod def clean_none_strings(cls, v): """Convertit les chaînes 'None'/'null'/'' en None""" if isinstance(v, str) and v.lower() in ('none', 'null', ''): return None return v def to_sage_dict(self) -> dict: """ Convertit le modèle en dictionnaire compatible avec creer_client() Mapping 1:1 avec les paramètres réels de la fonction """ stat01 = self.statistique01 or self.secteur ca = self.ca_annuel or self.sv_chiffre_affaires return { "intitule": self.intitule, "numero": self.numero, "type_tiers": self.type_tiers, "qualite": self.qualite, "classement": self.classement, "raccourci": self.raccourci, "siret": self.siret, "tva_intra": self.tva_intra, "code_naf": self.code_naf, "contact": self.contact, "adresse": self.adresse, "complement": self.complement, "code_postal": self.code_postal, "ville": self.ville, "region": self.region, "pays": self.pays, "telephone": self.telephone, "telecopie": self.telecopie, "email": self.email, "site_web": self.site_web, "portable": self.portable, "facebook": self.facebook, "linkedin": self.linkedin, "compte_general": self.compte_general, "categorie_tarifaire": self.categorie_tarifaire, "categorie_comptable": self.categorie_comptable, "taux01": self.taux01, "taux02": self.taux02, "taux03": self.taux03, "taux04": self.taux04, "statistique01": stat01, "statistique02": self.statistique02, "statistique03": self.statistique03, "statistique04": self.statistique04, "statistique05": self.statistique05, "statistique06": self.statistique06, "statistique07": self.statistique07, "statistique08": self.statistique08, "statistique09": self.statistique09, "statistique10": self.statistique10, "secteur": self.secteur, # Gardé pour compatibilité "encours_autorise": self.encours_autorise, "assurance_credit": self.assurance_credit, "langue": self.langue, "commercial_code": self.commercial_code, "lettrage_auto": self.lettrage_auto, "est_actif": self.est_actif, "type_facture": self.type_facture, "est_prospect": self.est_prospect, "bl_en_facture": self.bl_en_facture, "saut_page": self.saut_page, "validation_echeance": self.validation_echeance, "controle_encours": self.controle_encours, "exclure_relance": self.exclure_relance, "exclure_penalites": self.exclure_penalites, "bon_a_payer": self.bon_a_payer, "priorite_livraison": self.priorite_livraison, "livraison_partielle": self.livraison_partielle, "delai_transport": self.delai_transport, "delai_appro": self.delai_appro, "commentaire": self.commentaire, "section_analytique": self.section_analytique, "mode_reglement_code": self.mode_reglement_code, "surveillance_active": self.surveillance_active, "coface": self.coface, "forme_juridique": self.forme_juridique, "effectif": self.effectif, "sv_regularite": self.sv_regularite, "sv_cotation": self.sv_cotation, "sv_objet_maj": self.sv_objet_maj, "ca_annuel": ca, "sv_chiffre_affaires": self.sv_chiffre_affaires, "sv_resultat": self.sv_resultat, } class Config: json_schema_extra = { "example": { "intitule": "ENTREPRISE EXEMPLE SARL", "numero": "CLI00123", "type_tiers": 0, "qualite": "CLI", "compte_general": "411000", "est_prospect": False, "est_actif": True, "email": "contact@exemple.fr", "telephone": "0123456789", "adresse": "123 Rue de la Paix", "code_postal": "75001", "ville": "Paris", "pays": "France" } } class ClientUpdateRequest(BaseModel): """ Modèle pour modification d'un client existant TOUS les champs de ClientCreateRequest sont modifiables TOUS optionnels (seuls les champs fournis sont modifiés) """ intitule: Optional[str] = Field(None, max_length=69) qualite: Optional[str] = Field(None, max_length=17) classement: Optional[str] = Field(None, max_length=17) raccourci: Optional[str] = Field(None, max_length=7) siret: Optional[str] = Field(None, max_length=15) tva_intra: Optional[str] = Field(None, max_length=25) code_naf: Optional[str] = Field(None, max_length=7) contact: Optional[str] = Field(None, max_length=35) adresse: Optional[str] = Field(None, max_length=35) complement: Optional[str] = Field(None, max_length=35) code_postal: Optional[str] = Field(None, max_length=9) ville: Optional[str] = Field(None, max_length=35) region: Optional[str] = Field(None, max_length=25) pays: Optional[str] = Field(None, max_length=35) telephone: Optional[str] = Field(None, max_length=21) telecopie: Optional[str] = Field(None, max_length=21) email: Optional[str] = Field(None, max_length=69) site_web: Optional[str] = Field(None, max_length=69) portable: Optional[str] = Field(None, max_length=21) facebook: Optional[str] = Field(None, max_length=69) linkedin: Optional[str] = Field(None, max_length=69) compte_general: Optional[str] = Field(None, max_length=13) categorie_tarifaire: Optional[str] = None categorie_comptable: Optional[str] = None taux01: Optional[float] = None taux02: Optional[float] = None taux03: Optional[float] = None taux04: Optional[float] = None secteur: Optional[str] = Field(None, max_length=21) statistique01: Optional[str] = Field(None, max_length=21) statistique02: Optional[str] = Field(None, max_length=21) statistique03: Optional[str] = Field(None, max_length=21) statistique04: Optional[str] = Field(None, max_length=21) statistique05: Optional[str] = Field(None, max_length=21) statistique06: Optional[str] = Field(None, max_length=21) statistique07: Optional[str] = Field(None, max_length=21) statistique08: Optional[str] = Field(None, max_length=21) statistique09: Optional[str] = Field(None, max_length=21) statistique10: Optional[str] = Field(None, max_length=21) encours_autorise: Optional[float] = None assurance_credit: Optional[float] = None langue: Optional[int] = Field(None, ge=0) commercial_code: Optional[int] = None lettrage_auto: Optional[bool] = None est_actif: Optional[bool] = None type_facture: Optional[int] = Field(None, ge=0, le=2) est_prospect: Optional[bool] = None bl_en_facture: Optional[int] = Field(None, ge=0, le=1) saut_page: Optional[int] = Field(None, ge=0, le=1) validation_echeance: Optional[int] = Field(None, ge=0, le=1) controle_encours: Optional[int] = Field(None, ge=0, le=1) exclure_relance: Optional[int] = Field(None, ge=0, le=1) exclure_penalites: Optional[int] = Field(None, ge=0, le=1) bon_a_payer: Optional[int] = Field(None, ge=0, le=1) priorite_livraison: Optional[int] = Field(None, ge=0, le=5) livraison_partielle: Optional[int] = Field(None, ge=0, le=1) delai_transport: Optional[int] = Field(None, ge=0) delai_appro: Optional[int] = Field(None, ge=0) commentaire: Optional[str] = Field(None, max_length=35) section_analytique: Optional[str] = Field(None, max_length=13) mode_reglement_code: Optional[int] = None surveillance_active: Optional[int] = Field(None, ge=0, le=1) coface: Optional[str] = Field(None, max_length=25) forme_juridique: Optional[str] = Field(None, max_length=33) effectif: Optional[str] = Field(None, max_length=11) sv_regularite: Optional[str] = Field(None, max_length=3) sv_cotation: Optional[str] = Field(None, max_length=5) sv_objet_maj: Optional[str] = Field(None, max_length=61) ca_annuel: Optional[float] = None sv_chiffre_affaires: Optional[float] = None sv_resultat: Optional[float] = None @field_validator('siret') @classmethod def validate_siret(cls, v): if v and v.lower() not in ('none', 'null', ''): cleaned = v.replace(' ', '').replace('-', '') if len(cleaned) not in (14, 15): raise ValueError('Le SIRET doit contenir 14 ou 15 caractères') return cleaned return None @field_validator('email') @classmethod def validate_email(cls, v): if v and v.lower() not in ('none', 'null', ''): v = v.strip() if '@' not in v: raise ValueError('Format email invalide') return v return None @field_validator('raccourci') @classmethod def validate_raccourci(cls, v): if v and v.lower() not in ('none', 'null', ''): return v.upper().strip()[:7] return None @field_validator( 'adresse', 'code_postal', 'ville', 'pays', 'telephone', 'tva_intra', 'contact', 'complement', mode='before' ) @classmethod def clean_none_strings(cls, v): if isinstance(v, str) and v.lower() in ('none', 'null', ''): return None return v class Config: json_schema_extra = { "example": { "email": "nouveau@email.fr", "telephone": "0198765432", "portable": "0687654321", "adresse": "456 Avenue Nouvelle", "ville": "Lyon" } } class UserResponse(BaseModel): """Modèle de réponse pour un utilisateur""" id: str email: str nom: str prenom: str role: str is_verified: bool is_active: bool created_at: str last_login: Optional[str] = None failed_login_attempts: int = 0 class Config: from_attributes = True class FournisseurCreateAPIRequest(BaseModel): intitule: str = Field( ..., min_length=1, max_length=69, description="Raison sociale du fournisseur" ) compte_collectif: str = Field( "401000", description="Compte comptable fournisseur (ex: 401000)" ) num: Optional[str] = Field( None, max_length=17, description="Code fournisseur souhaité (optionnel)" ) 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) email: Optional[EmailStr] = None telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) class Config: json_schema_extra = { "example": { "intitule": "ACME SUPPLIES SARL", "compte_collectif": "401000", "num": "FOUR001", "adresse": "15 Rue du Commerce", "code_postal": "75001", "ville": "Paris", "pays": "France", "email": "contact@acmesupplies.fr", "telephone": "0145678901", "siret": "12345678901234", "tva_intra": "FR12345678901", } } class FournisseurUpdateRequest(BaseModel): """Modèle pour modification d'un fournisseur 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) ville: Optional[str] = Field(None, max_length=35) pays: Optional[str] = Field(None, max_length=35) email: Optional[EmailStr] = None telephone: Optional[str] = Field(None, max_length=21) siret: Optional[str] = Field(None, max_length=14) tva_intra: Optional[str] = Field(None, max_length=25) class Config: json_schema_extra = { "example": { "intitule": "ACME SUPPLIES MODIFIÉ", "email": "nouveau@acme.fr", "telephone": "0198765432", } } class DevisUpdateRequest(BaseModel): """Modèle pour modification d'un devis existant""" date_devis: Optional[date] = None date_livraison: Optional[date] = None reference: Optional[str] = None lignes: Optional[List[LigneDevis]] = None statut: Optional[int] = Field(None, ge=0, le=6) class Config: json_schema_extra = { "example": { "date_devis": "2024-01-15", "date_livraison": "2024-01-15", "reference": "DEV-001", "lignes": [ { "article_code": "ART001", "quantite": 5.0, "prix_unitaire_ht": 100.0, "remise_pourcentage": 10.0, } ], "statut": 2, } } class LigneCommande(BaseModel): """Ligne de commande""" article_code: str quantite: float remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() class CommandeCreateRequest(BaseModel): """Création d'une commande""" client_id: str date_commande: Optional[date] = None date_livraison: Optional[date] = None lignes: List[LigneCommande] reference: Optional[str] = None class Config: json_schema_extra = { "example": { "client_id": "CLI000001", "date_commande": "2024-01-15", "reference": "CMD-EXT-001", "lignes": [ { "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, "remise_pourcentage": 5.0, } ], } } class CommandeUpdateRequest(BaseModel): """Modification d'une commande existante""" date_commande: Optional[date] = None date_livraison: Optional[date] = None lignes: Optional[List[LigneCommande]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None class Config: json_schema_extra = { "example": { "date_commande": "2024-01-15", "date_livraison": "2024-01-15", "reference": "CMD-EXT-001", "lignes": [ { "article_code": "ART001", "quantite": 15.0, "prix_unitaire_ht": 45.0, } ], "statut": 2, } } class LigneLivraison(BaseModel): """Ligne de livraison""" article_code: str quantite: float remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() class LivraisonCreateRequest(BaseModel): """Création d'une livraison""" client_id: str date_livraison: Optional[date] = None date_livraison_prevue: Optional[date] = None lignes: List[LigneLivraison] reference: Optional[str] = None class Config: json_schema_extra = { "example": { "client_id": "CLI000001", "date_livraison": "2024-01-15", "reference": "BL-EXT-001", "lignes": [ { "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, "remise_pourcentage": 5.0, } ], } } class LivraisonUpdateRequest(BaseModel): """Modification d'une livraison existante""" date_livraison: Optional[date] = None date_livraison_prevue: Optional[date] = None lignes: Optional[List[LigneLivraison]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None class Config: json_schema_extra = { "example": { "date_livraison": "2024-01-15", "date_livraison_prevue": "2024-01-15", "reference": "BL-EXT-001", "lignes": [ { "article_code": "ART001", "quantite": 15.0, "prix_unitaire_ht": 45.0, } ], "statut": 2, } } class LigneAvoir(BaseModel): """Ligne d'avoir""" article_code: str quantite: float remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() class AvoirCreateRequest(BaseModel): """Création d'un avoir""" client_id: str date_avoir: Optional[date] = None date_livraison: Optional[date] = None lignes: List[LigneAvoir] reference: Optional[str] = None class Config: json_schema_extra = { "example": { "client_id": "CLI000001", "date_avoir": "2024-01-15", "reference": "AV-EXT-001", "lignes": [ { "article_code": "ART001", "quantite": 5.0, "prix_unitaire_ht": 50.0, "remise_pourcentage": 0.0, } ], } } class AvoirUpdateRequest(BaseModel): """Modification d'un avoir existant""" date_avoir: Optional[date] = None date_livraison: Optional[date] = None lignes: Optional[List[LigneAvoir]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None class Config: json_schema_extra = { "example": { "date_avoir": "2024-01-15", "date_livraison": "2024-01-15", "reference": "AV-EXT-001", "lignes": [ { "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 45.0, } ], "statut": 2, } } class LigneFacture(BaseModel): """Ligne de facture""" article_code: str quantite: float remise_pourcentage: Optional[float] = 0.0 @field_validator("article_code", mode="before") def strip_insecables(cls, v): return v.replace("\xa0", "").strip() class FactureCreateRequest(BaseModel): """Création d'une facture""" client_id: str date_facture: Optional[date] = None date_livraison: Optional[date] = None lignes: List[LigneFacture] reference: Optional[str] = None class Config: json_schema_extra = { "example": { "client_id": "CLI000001", "date_facture": "2024-01-15", "reference": "FA-EXT-001", "lignes": [ { "article_code": "ART001", "quantite": 10.0, "prix_unitaire_ht": 50.0, "remise_pourcentage": 5.0, } ], } } class FactureUpdateRequest(BaseModel): """Modification d'une facture existante""" date_facture: Optional[date] = None date_livraison: Optional[date] = None lignes: Optional[List[LigneFacture]] = None statut: Optional[int] = Field(None, ge=0, le=6) reference: Optional[str] = None class Config: json_schema_extra = { "example": { "date_facture": "2024-01-15", "date_livraison": "2024-01-15", "lignes": [ { "article_code": "ART001", "quantite": 15.0, "prix_unitaire_ht": 45.0, } ], "statut": 2, } } 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") prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT") prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT") stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial") stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres") unite_vente: Optional[str] = Field("UN", max_length=4, description="Unité") tva_code: Optional[str] = Field(None, max_length=5, description="Code TVA") description: Optional[str] = Field(None, description="Description") 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_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 complet d'une famille avec données comptables et fournisseur""" 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") est_detail: bool = Field(..., description="True si type Détail") unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") unite_poids: Optional[str] = Field(None, description="Unité de poids") coef: Optional[float] = Field(None, description="Coefficient multiplicateur") suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") delai: Optional[int] = Field(None, description="Délai de livraison (jours)") nb_colis: Optional[int] = Field(None, description="Nombre de colis") code_fiscal: Optional[str] = Field(None, description="Code fiscal par défaut") escompte: Optional[bool] = Field(None, description="Escompte autorisé") est_centrale: Optional[bool] = Field(None, description="Famille d'achat centralisé") nature: Optional[int] = Field(None, description="Nature de la famille") pays: Optional[str] = Field(None, description="Pays d'origine") categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)") categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)") categorie_3: Optional[int] = Field(None, description="Catégorie comptable 3 (CL_No3)") categorie_4: Optional[int] = Field(None, description="Catégorie comptable 4 (CL_No4)") stat_01: Optional[str] = Field(None, description="Statistique libre 1") stat_02: Optional[str] = Field(None, description="Statistique libre 2") stat_03: Optional[str] = Field(None, description="Statistique libre 3") stat_04: Optional[str] = Field(None, description="Statistique libre 4") stat_05: Optional[str] = Field(None, description="Statistique libre 5") hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques") vente_debit: Optional[bool] = Field(None, description="Vente au débit") non_imprimable: Optional[bool] = Field(None, description="Non imprimable sur documents") contremarque: Optional[bool] = Field(None, description="Article en contremarque") fact_poids: Optional[bool] = Field(None, description="Facturation au poids") fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") publie: Optional[bool] = Field(None, description="Publié (e-commerce)") racine_reference: Optional[str] = Field(None, description="Racine pour génération auto de références") racine_code_barre: Optional[str] = Field(None, description="Racine pour génération auto de codes-barres") raccourci: Optional[str] = Field(None, description="Raccourci clavier") sous_traitance: Optional[bool] = Field(None, description="Famille en sous-traitance") fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)") criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)") compte_vente: Optional[str] = Field(None, description="Compte général de vente") compte_auxiliaire_vente: Optional[str] = Field(None, description="Compte auxiliaire de vente") tva_vente_1: Optional[str] = Field(None, description="Code TVA vente principal") tva_vente_2: Optional[str] = Field(None, description="Code TVA vente secondaire") tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire") type_facture_vente: Optional[int] = Field(None, description="Type de facture vente") compte_achat: Optional[str] = Field(None, description="Compte général d'achat") compte_auxiliaire_achat: Optional[str] = Field(None, description="Compte auxiliaire d'achat") tva_achat_1: Optional[str] = Field(None, description="Code TVA achat principal") tva_achat_2: Optional[str] = Field(None, description="Code TVA achat secondaire") tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire") type_facture_achat: Optional[int] = Field(None, description="Type de facture achat") compte_stock: Optional[str] = Field(None, description="Compte de stock") compte_auxiliaire_stock: Optional[str] = Field(None, description="Compte auxiliaire de stock") fournisseur_principal: Optional[str] = Field(None, description="N° compte fournisseur principal") fournisseur_unite: Optional[str] = Field(None, description="Unité d'achat fournisseur") fournisseur_conversion: Optional[float] = Field(None, description="Coefficient de conversion") fournisseur_delai_appro: Optional[int] = Field(None, description="Délai d'approvisionnement (jours)") fournisseur_garantie: Optional[int] = Field(None, description="Garantie fournisseur (mois)") fournisseur_colisage: Optional[int] = Field(None, description="Colisage fournisseur") fournisseur_qte_mini: Optional[float] = Field(None, description="Quantité minimum de commande") fournisseur_qte_mont: Optional[float] = Field(None, description="Quantité montant") fournisseur_devise: Optional[int] = Field(None, description="Devise fournisseur (0=Euro)") fournisseur_remise: Optional[float] = Field(None, description="Remise fournisseur (%)") fournisseur_type_remise: Optional[int] = Field(None, description="Type de remise (0=%, 1=Montant)") nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") FA_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille") FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé") FA_Type: Optional[int] = Field(None, description="[Legacy] Type") CG_NumVte: Optional[str] = Field(None, description="[Legacy] Compte vente") CG_NumAch: Optional[str] = Field(None, description="[Legacy] Compte achat") class Config: json_schema_extra = { "example": { "code": "ELECT", "intitule": "Électronique et Informatique", "type": 0, "type_libelle": "Détail", "est_total": False, "est_detail": True, "unite_vente": "U", "unite_poids": "KG", "coef": 2.5, "suivi_stock": True, "garantie": 24, "delai": 5, "nb_colis": 1, "code_fiscal": "C19", "escompte": True, "est_centrale": False, "nature": 0, "pays": "FR", "categorie_1": 1, "categorie_2": 0, "categorie_3": 0, "categorie_4": 0, "stat_01": "HIGH_TECH", "stat_02": "", "stat_03": "", "stat_04": "", "stat_05": "", "hors_statistique": False, "vente_debit": False, "non_imprimable": False, "contremarque": False, "fact_poids": False, "fact_forfait": False, "publie": True, "racine_reference": "ELEC", "racine_code_barre": "339", "raccourci": "F5", "sous_traitance": False, "fictif": False, "criticite": 2, "compte_vente": "707100", "compte_auxiliaire_vente": "", "tva_vente_1": "C19", "tva_vente_2": "", "tva_vente_3": "", "type_facture_vente": 0, "compte_achat": "607100", "compte_auxiliaire_achat": "", "tva_achat_1": "C19", "tva_achat_2": "", "tva_achat_3": "", "type_facture_achat": 0, "compte_stock": "350000", "compte_auxiliaire_stock": "", "fournisseur_principal": "FTECH001", "fournisseur_unite": "U", "fournisseur_conversion": 1.0, "fournisseur_delai_appro": 7, "fournisseur_garantie": 12, "fournisseur_colisage": 10, "fournisseur_qte_mini": 5.0, "fournisseur_qte_mont": 100.0, "fournisseur_devise": 0, "fournisseur_remise": 5.0, "fournisseur_type_remise": 0, "nb_articles": 156 } } class FamilleListResponse(BaseModel): """Réponse pour la liste des familles""" familles: list[FamilleResponse] total: int filtre: Optional[str] = None inclure_totaux: bool = True class Config: json_schema_extra = { "example": { "familles": [], "total": 42, "filtre": "ELECT", "inclure_totaux": False } } class MouvementStockLigneRequest(BaseModel): 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") 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): """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""" article_ref: str = Field(..., description="Numéro d'article") 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") class ContactCreate(BaseModel): """Données pour créer ou modifier un contact""" numero: str = Field(..., description="Code du client parent (obligatoire)") civilite: Optional[str] = Field(None, description="M., Mme, Mlle, Société") nom: str = Field(..., description="Nom de famille (obligatoire)") prenom: Optional[str] = Field(None, description="Prénom") fonction: Optional[str] = Field(None, description="Fonction/Titre") est_defaut: Optional[bool] = Field(False, description="Définir comme contact par défaut du client") service_code: Optional[int] = Field(None, description="Code du service") 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") facebook: Optional[str] = Field(None, description="URL Facebook") linkedin: Optional[str] = Field(None, description="URL LinkedIn") skype: Optional[str] = Field(None, description="Identifiant Skype") @validator("civilite") def validate_civilite(cls, v): if v and v not in ["M.", "Mme", "Mlle", "Société"]: raise ValueError("Civilité doit être: M., Mme, Mlle ou Société") return v class Config: json_schema_extra = { "example": { "numero": "CLI000001", "civilite": "M.", "nom": "Dupont", "prenom": "Jean", "fonction": "Directeur Commercial", "telephone": "0123456789", "portable": "0612345678", "email": "j.dupont@exemple.fr", "linkedin": "https://linkedin.com/in/jeandupont", "est_defaut": True } } class ContactUpdate(BaseModel): """Données pour modifier un contact (tous champs optionnels)""" civilite: Optional[str] = None nom: Optional[str] = None prenom: Optional[str] = None fonction: Optional[str] = None service_code: Optional[int] = None telephone: Optional[str] = None portable: Optional[str] = None telecopie: Optional[str] = None email: Optional[str] = None facebook: Optional[str] = None linkedin: Optional[str] = None skype: Optional[str] = None est_defaut: Optional[bool] = None templates_signature_email = { "demande_signature": { "id": "demande_signature", "nom": "Demande de Signature Électronique", "sujet": "Signature requise - {{TYPE_DOC}} {{NUMERO}}", "corps_html": """

Signature Électronique Requise

Bonjour {{NOM_SIGNATAIRE}},

Nous vous invitons à signer électroniquement le document suivant :

Type de document {{TYPE_DOC}}
Numéro {{NUMERO}}
Date {{DATE}}
Montant TTC {{MONTANT_TTC}} €

Cliquez sur le bouton ci-dessous pour accéder au document et apposer votre signature électronique sécurisée :

✍️ Signer le document

Important : Ce lien de signature est valable pendant 30 jours. Nous vous recommandons de signer ce document dès que possible.

🔒 Signature électronique sécurisée
Votre signature est protégée par notre partenaire de confiance Universign, certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera horodaté de manière infalsifiable.

Vous avez des questions ? Contactez-nous à {{CONTACT_EMAIL}}

Cet email a été envoyé automatiquement par le système Sage 100c Dataven.
Si vous avez reçu cet email par erreur, veuillez nous en informer.

""", "variables_disponibles": [ "NOM_SIGNATAIRE", "TYPE_DOC", "NUMERO", "DATE", "MONTANT_TTC", "SIGNER_URL", "CONTACT_EMAIL", ], }, "signature_confirmee": { "id": "signature_confirmee", "nom": "Confirmation de Signature", "sujet": "Document signé - {{TYPE_DOC}} {{NUMERO}}", "corps_html": """

Document Signé avec Succès

Bonjour {{NOM_SIGNATAIRE}},

Nous confirmons la signature électronique du document suivant :

Document {{TYPE_DOC}} {{NUMERO}}
Signé le {{DATE_SIGNATURE}}
ID Transaction {{TRANSACTION_ID}}

Le document signé a été automatiquement archivé et est disponible dans votre espace client. Un certificat de signature électronique conforme eIDAS a été généré.

Signature certifiée : Ce document a été signé avec une signature électronique qualifiée, ayant la même valeur juridique qu'une signature manuscrite conformément au règlement eIDAS.

Merci pour votre confiance. Notre équipe reste à votre disposition pour toute question.

Contact : {{CONTACT_EMAIL}}

Sage 100c Dataven - Système de signature électronique sécurisée

""", "variables_disponibles": [ "NOM_SIGNATAIRE", "TYPE_DOC", "NUMERO", "DATE_SIGNATURE", "TRANSACTION_ID", "CONTACT_EMAIL", ], }, "relance_signature": { "id": "relance_signature", "nom": "Relance Signature en Attente", "sujet": "⏰ Rappel - Signature en attente {{TYPE_DOC}} {{NUMERO}}", "corps_html": """

⏰ Signature en Attente

Bonjour {{NOM_SIGNATAIRE}},

Nous vous avons envoyé il y a {{NB_JOURS}} jours un document à signer électroniquement. Nous constatons que celui-ci n'a pas encore été signé.

Document en attente : {{TYPE_DOC}} {{NUMERO}}

⏳ Le lien de signature expirera dans {{JOURS_RESTANTS}} jours

Pour éviter tout retard dans le traitement de votre dossier, nous vous invitons à signer ce document dès maintenant :

✍️ Signer maintenant

Si vous rencontrez des difficultés ou avez des questions, n'hésitez pas à nous contacter.

Contact : {{CONTACT_EMAIL}}

Sage 100c Dataven - Relance automatique

""", "variables_disponibles": [ "NOM_SIGNATAIRE", "TYPE_DOC", "NUMERO", "NB_JOURS", "JOURS_RESTANTS", "SIGNER_URL", "CONTACT_EMAIL", ], }, } async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str, doc_data: Dict, session: AsyncSession, ) -> Dict: import requests try: api_key = settings.universign_api_key api_url = settings.universign_api_url auth = (api_key, "") logger.info(f" Démarrage processus Universign pour {email}") logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})") if not pdf_bytes or len(pdf_bytes) == 0: raise Exception("Le PDF généré est vide") logger.info(f"PDF valide : {len(pdf_bytes)} octets") logger.info("ÉTAPE 1/6 : Création transaction") response = requests.post( f"{api_url}/transactions", auth=auth, json={ "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", "language": "fr", }, timeout=30, ) if response.status_code != 200: logger.error(f"Erreur création transaction: {response.text}") raise Exception(f"Erreur création transaction: {response.status_code}") transaction_id = response.json().get("id") logger.info(f"Transaction créée: {transaction_id}") logger.info("ÉTAPE 2/6 : Upload PDF") files = { "file": ( f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf", ) } response = requests.post( f"{api_url}/files", auth=auth, files=files, timeout=60, ) if response.status_code not in [200, 201]: logger.error(f"Erreur upload: {response.text}") raise Exception(f"Erreur upload fichier: {response.status_code}") file_id = response.json().get("id") logger.info(f"Fichier uploadé: {file_id}") logger.info("📋 ÉTAPE 3/6 : Ajout document à transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, data={"document": file_id}, timeout=30, ) if response.status_code not in [200, 201]: logger.error(f"Erreur ajout document: {response.text}") raise Exception(f"Erreur ajout document: {response.status_code}") document_id = response.json().get("id") logger.info(f"Document ajouté: {document_id}") logger.info("✍️ ÉTAPE 4/6 : Création champ signature") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, data={ "type": "signature", }, timeout=30, ) if response.status_code not in [200, 201]: logger.error(f"Erreur création champ: {response.text}") raise Exception(f"Erreur création champ: {response.status_code}") field_id = response.json().get("id") logger.info(f"Champ créé: {field_id}") logger.info(" ÉTAPE 5/6 : Liaison signataire au champ") response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers auth=auth, data={ "signer": email, "field": field_id, }, timeout=30, ) if response.status_code not in [200, 201]: logger.error(f"Erreur liaison signataire: {response.text}") raise Exception(f"Erreur liaison signataire: {response.status_code}") logger.info(f"Signataire lié: {email}") logger.info("🚀 ÉTAPE 6/6 : Démarrage transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 ) if response.status_code not in [200, 201]: logger.error(f"Erreur démarrage: {response.text}") raise Exception(f"Erreur démarrage: {response.status_code}") final_data = response.json() logger.info("Transaction démarrée") logger.info("🔗 Récupération URL de signature") signer_url = "" if final_data.get("actions"): for action in final_data["actions"]: if action.get("url"): signer_url = action["url"] break if not signer_url and final_data.get("signers"): for signer in final_data["signers"]: if signer.get("email") == email: signer_url = signer.get("url", "") break if not signer_url: logger.error(f"URL introuvable dans: {final_data}") raise ValueError("URL de signature non retournée par Universign") logger.info("URL récupérée") logger.info(" Préparation email") template = templates_signature_email["demande_signature"] type_labels = { 0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir", } variables = { "NOM_SIGNATAIRE": nom, "TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"), "NUMERO": doc_id, "DATE": doc_data.get("date", datetime.now().strftime("%d/%m/%Y")), "MONTANT_TTC": f"{doc_data.get('montant_ttc', 0):.2f}", "SIGNER_URL": signer_url, "CONTACT_EMAIL": settings.smtp_from, } sujet = template["sujet"] corps = template["corps_html"] for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) email_log = EmailLog( id=str(uuid.uuid4()), destinataire=email, sujet=sujet, corps_html=corps, document_ids=doc_id, type_document=doc_data.get("type_doc"), statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.flush() email_queue.enqueue(email_log.id) logger.info(f"Email mis en file pour {email}") logger.info("🎉 Processus terminé avec succès") return { "transaction_id": transaction_id, "signer_url": signer_url, "statut": "ENVOYE", "email_log_id": email_log.id, "email_sent": True, } except Exception as e: logger.error(f"Erreur Universign: {e}", exc_info=True) return { "error": str(e), "statut": "ERREUR", "email_sent": False, } async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" import requests try: response = requests.get( f"{settings.universign_api_url}/transactions/{transaction_id}", auth=(settings.universign_api_key, ""), timeout=10, ) if response.status_code == 200: data = response.json() statut_map = { "draft": "EN_ATTENTE", "started": "EN_ATTENTE", "completed": "SIGNE", "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE", } return { "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), "date_signature": data.get("completed_at"), } else: return {"statut": "ERREUR"} except Exception as e: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} @asynccontextmanager async def lifespan(app: FastAPI): await init_db() logger.info("Base de données initialisée") email_queue.session_factory = async_session_factory email_queue.sage_client = sage_client logger.info("sage_client injecté dans email_queue") email_queue.start(num_workers=settings.max_email_workers) logger.info("Email queue démarrée") yield email_queue.stop() logger.info("👋 Services arrêtés") app = FastAPI( title="API Sage 100c Dataven", version="2.0.0", description="API de gestion commerciale - VPS Linux", lifespan=lifespan, openapi_tags=TAGS_METADATA, ) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], allow_credentials=True, ) app.include_router(auth_router) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def obtenir_clients(query: Optional[str] = Query(None)): try: clients = sage_client.lister_clients(filtre=query or "") return [ClientDetails(**c) for c in clients] except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) @app.get("/clients/{code}", response_model=ClientDetails , tags=["Clients"]) async def lire_client_detail(code: str): try: client = sage_client.lire_client(code) if not client: raise HTTPException(404, f"Client {code} introuvable") return ClientDetails(**client) except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture client {code}: {e}") raise HTTPException(500, str(e)) @app.put("/clients/{code}", tags=["Clients"]) async def modifier_client( code: str, client_update: ClientUpdateRequest, session: AsyncSession = Depends(get_session), ): try: resultat = sage_client.modifier_client( code, client_update.dict(exclude_none=True) ) logger.info(f"Client {code} modifié avec succès") return { "success": True, "message": f"Client {code} modifié avec succès", "client": resultat, } except ValueError as e: logger.warning(f"Erreur métier modification client {code}: {e}") raise HTTPException(404, str(e)) except Exception as e: logger.error(f"Erreur technique modification client {code}: {e}") raise HTTPException(500, str(e)) @app.post("/clients", status_code=201, tags=["Clients"]) async def ajouter_client( client: ClientCreateRequest, session: AsyncSession = Depends(get_session) ): try: nouveau_client = sage_client.creer_client(client.model_dump(mode='json')) logger.info(f"Client créé via API: {nouveau_client.get('numero')}") return jsonable_encoder({ "success": True, "message": "Client créé avec succès", "data": nouveau_client, }) except Exception as e: logger.error(f"Erreur lors de la création du client: {e}") status = 400 if "existe déjà" in str(e) else 500 raise HTTPException(status, str(e)) @app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): try: articles = sage_client.lister_articles(filtre=query or "") return [ArticleResponse(**a) for a in articles] except Exception as e: 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"], ) async def creer_article(article: ArticleCreateRequest): try: 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", ) article_data = article.dict(exclude_unset=True) logger.info(f"Création article: {article.reference} - {article.designation}") resultat = sage_client.creer_article(article_data) logger.info( f"Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" ) return ArticleResponse(**resultat) except ValueError as e: logger.warning(f"Erreur métier création article: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except HTTPException: raise except Exception as e: 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)}", ) @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(...), ): try: 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.", ) logger.info(f"Modification article {reference}: {list(article_data.keys())}") resultat = sage_client.modifier_article(reference, article_data) 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: logger.warning(f"Erreur métier modification article: {e}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except HTTPException: raise except Exception as e: 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)}", ) @app.get("/articles/{reference}", response_model=ArticleResponse, tags=["Articles"]) 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", ) 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)}", ) @app.get("/articles/all") def lister_articles(filtre: str = ""): try: articles = sage_client.lister_articles(filtre) 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): try: devis_data = { "client_id": devis.client_id, "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, "date_livraison": ( devis.date_livraison.isoformat() if devis.date_livraison else None ), "reference": devis.reference, "lignes": [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in devis.lignes ], } resultat = sage_client.creer_devis(devis_data) logger.info(f"Devis créé: {resultat.get('numero_devis')}") return DevisResponse( id=resultat["numero_devis"], client_id=devis.client_id, date_devis=resultat["date_devis"], montant_total_ht=resultat["total_ht"], montant_total_ttc=resultat["total_ttc"], nb_lignes=resultat["nb_lignes"], ) except Exception as e: logger.error(f"Erreur création devis: {e}") raise HTTPException(500, str(e)) @app.put("/devis/{id}", tags=["Devis"]) async def modifier_devis( id: str, devis_update: DevisUpdateRequest, session: AsyncSession = Depends(get_session), ): try: update_data = {} if devis_update.date_devis: update_data["date_devis"] = devis_update.date_devis.isoformat() if devis_update.lignes is not None: update_data["lignes"] = [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in devis_update.lignes ] if devis_update.statut is not None: update_data["statut"] = devis_update.statut if devis_update.reference is not None: update_data["reference"] = devis_update.reference resultat = sage_client.modifier_devis(id, update_data) logger.info(f"Devis {id} modifié avec succès") return { "success": True, "message": f"Devis {id} modifié avec succès", "devis": resultat, } except HTTPException: raise except Exception as e: logger.error(f"Erreur modification devis {id}: {e}") raise HTTPException(500, str(e)) @app.post("/commandes", status_code=201, tags=["Commandes"]) async def creer_commande( commande: CommandeCreateRequest, session: AsyncSession = Depends(get_session) ): try: commande_data = { "client_id": commande.client_id, "date_commande": ( commande.date_commande.isoformat() if commande.date_commande else None ), "date_livraison": ( commande.date_livraison.isoformat() if commande.date_livraison else None ), "reference": commande.reference, "lignes": [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in commande.lignes ], } resultat = sage_client.creer_commande(commande_data) logger.info(f"Commande créée: {resultat.get('numero_commande')}") return { "success": True, "message": "Commande créée avec succès", "data": { "numero_commande": resultat["numero_commande"], "client_id": commande.client_id, "date_commande": resultat["date_commande"], "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], "reference": commande.reference, }, } except HTTPException: raise except Exception as e: logger.error(f"Erreur création commande: {e}") raise HTTPException(500, str(e)) @app.put("/commandes/{id}", tags=["Commandes"]) async def modifier_commande( id: str, commande_update: CommandeUpdateRequest, session: AsyncSession = Depends(get_session), ): try: update_data = {} if commande_update.date_commande: update_data["date_commande"] = commande_update.date_commande.isoformat() if commande_update.lignes is not None: update_data["lignes"] = [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in commande_update.lignes ] if commande_update.statut is not None: update_data["statut"] = commande_update.statut if commande_update.reference is not None: update_data["reference"] = commande_update.reference resultat = sage_client.modifier_commande(id, update_data) logger.info(f"Commande {id} modifiée avec succès") return { "success": True, "message": f"Commande {id} modifiée avec succès", "commande": resultat, } except HTTPException: raise except Exception as e: logger.error(f"Erreur modification commande {id}: {e}") raise HTTPException(500, str(e)) @app.get("/devis", tags=["Devis"]) async def lister_devis( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), inclure_lignes: bool = Query( True, description="Inclure les lignes de chaque devis" ), ): try: devis_list = sage_client.lister_devis( limit=limit, statut=statut, inclure_lignes=inclure_lignes ) return devis_list except Exception as e: logger.error(f"Erreur liste devis: {e}") raise HTTPException(500, str(e)) @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): try: devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") return {"success": True, "data": devis} except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture devis: {e}") raise HTTPException(500, str(e)) @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): try: pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) return StreamingResponse( iter([pdf_bytes]), media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename=Devis_{id}.pdf"}, ) except Exception as e: logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) @app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) async def telecharger_document_pdf( type_doc: int = Path( ..., description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", ), numero: str = Path(..., description="Numéro du document"), ): try: types_labels = { 0: "Devis", 10: "Commande", 20: "Preparation", 30: "BonLivraison", 40: "BonRetour", 50: "Avoir", 60: "Facture", } if type_doc not in types_labels: raise HTTPException( 400, f"Type de document invalide: {type_doc}. " f"Types valides: {list(types_labels.keys())}", ) label = types_labels[type_doc] logger.info(f"Génération PDF: {label} {numero} (type={type_doc})") pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) if not pdf_bytes: raise HTTPException(500, f"Le PDF du document {numero} est vide") logger.info(f"PDF généré: {len(pdf_bytes)} octets") filename = f"{label}_{numero}.pdf" return StreamingResponse( iter([pdf_bytes]), media_type="application/pdf", headers={ "Content-Disposition": f"attachment; filename={filename}", "Content-Length": str(len(pdf_bytes)), }, ) except HTTPException: raise except Exception as e: logger.error( f"Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True ) raise HTTPException(500, f"Erreur génération PDF: {str(e)}") @app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session) ): try: tous_destinataires = [request.destinataire] + request.cc + request.cci email_logs = [] for dest in tous_destinataires: email_log = EmailLog( id=str(uuid.uuid4()), destinataire=dest, sujet=request.sujet, corps_html=request.corps_html, document_ids=id, type_document=TypeDocument.DEVIS, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.flush() email_queue.enqueue(email_log.id) email_logs.append(email_log.id) await session.commit() logger.info( f"Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)" ) return { "success": True, "email_log_ids": email_logs, "devis_id": id, "message": f"{len(tous_destinataires)} email(s) en file d'attente", } except HTTPException: raise except Exception as e: logger.error(f"Erreur envoi email: {e}") raise HTTPException(500, str(e)) @app.put("/devis/{id}/statut", tags=["Devis"]) async def changer_statut_devis( id: str, nouveau_statut: int = Query( ..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé" ), ): try: devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") statut_actuel = devis_existant.get("statut", 0) if statut_actuel == 5: raise HTTPException( 400, f"Le devis {id} a déjà été transformé et ne peut plus changer de statut", ) if statut_actuel == 6: raise HTTPException( 400, f"Le devis {id} est annulé et ne peut plus changer de statut" ) resultat = sage_client.changer_statut_devis(id, nouveau_statut) logger.info(f"Statut devis {id} changé: {statut_actuel} → {nouveau_statut}") return { "success": True, "devis_id": id, "statut_ancien": resultat.get("statut_ancien", statut_actuel), "statut_nouveau": resultat.get("statut_nouveau", nouveau_statut), "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}", } except HTTPException: raise except Exception as e: logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande(id: str): try: commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande: raise HTTPException(404, f"Commande {id} introuvable") return commande except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture commande: {e}") raise HTTPException(500, str(e)) @app.get("/commandes", tags=["Commandes"]) async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): try: commandes = sage_client.lister_commandes(limit=limit, statut=statut) return commandes except Exception as e: logger.error(f"Erreur liste commandes: {e}") raise HTTPException(500, str(e)) @app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): try: resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.DEVIS, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.BON_COMMANDE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), succes=True, ) session.add(workflow_log) await session.commit() logger.info( f"Transformation: Devis {id} → Commande {resultat['document_cible']}" ) return { "success": True, "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], "statut_devis_mis_a_jour": True, } except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) @app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): try: resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.BON_COMMANDE, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.FACTURE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), succes=True, ) session.add(workflow_log) await session.commit() logger.info( f"Transformation: Commande {id} → Facture {resultat['document_cible']}" ) return { "success": True, "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], } except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) def normaliser_type_doc(type_doc: int) -> int: TYPES_AUTORISES = {0, 10, 30, 50, 60} if type_doc not in TYPES_AUTORISES: raise ValueError( f"type_doc invalide ({type_doc}). Valeurs autorisées : {sorted(TYPES_AUTORISES)}" ) return type_doc if type_doc == 0 else type_doc // 10 @app.post("/signature/universign/send", tags=["Signatures"]) async def envoyer_signature_optimise( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): try: doc = sage_client.lire_document( demande.doc_id, normaliser_type_doc(demande.type_doc) ) if not doc: raise HTTPException(404, f"Document {demande.doc_id} introuvable") pdf_bytes = email_queue._generate_pdf( demande.doc_id, normaliser_type_doc(demande.type_doc) ) doc_data = { "type_doc": demande.type_doc, "type_label": { 0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir", }.get(demande.type_doc, "Document"), "montant_ttc": doc.get("total_ttc", 0), "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")), } resultat = await universign_envoyer( doc_id=demande.doc_id, pdf_bytes=pdf_bytes, email=demande.email_signataire, nom=demande.nom_signataire, doc_data=doc_data, session=session, ) if "error" in resultat: raise HTTPException(500, resultat["error"]) signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=demande.doc_id, type_document=demande.type_doc, transaction_id=resultat["transaction_id"], signer_url=resultat["signer_url"], email_signataire=demande.email_signataire, nom_signataire=demande.nom_signataire, statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), ) session.add(signature_log) await session.commit() sage_client.mettre_a_jour_champ_libre( demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"] ) logger.info( f"Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})" ) return { "success": True, "transaction_id": resultat["transaction_id"], "signer_url": resultat["signer_url"], "email_sent": resultat["email_sent"], "email_log_id": resultat.get("email_log_id"), "message": f"Demande de signature envoyée à {demande.email_signataire}", } except HTTPException: raise except Exception as e: logger.error(f"Erreur signature: {e}") raise HTTPException(500, str(e)) @app.post("/webhooks/universign", tags=["Signatures"]) async def webhook_universign( request: Request, session: AsyncSession = Depends(get_session) ): try: payload = await request.json() event_type = payload.get("event") transaction_id = payload.get("transaction_id") if not transaction_id: logger.warning("Webhook sans transaction_id") return {"status": "ignored"} query = select(SignatureLog).where( SignatureLog.transaction_id == transaction_id ) result = await session.execute(query) signature_log = result.scalar_one_or_none() if not signature_log: logger.warning(f"Transaction {transaction_id} introuvable en DB") return {"status": "not_found"} if event_type == "transaction.completed": signature_log.statut = StatutSignatureEnum.SIGNE signature_log.date_signature = datetime.now() logger.info(f"Signature confirmée: {signature_log.document_id}") template = templates_signature_email["signature_confirmee"] type_labels = { 0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir", } variables = { "NOM_SIGNATAIRE": signature_log.nom_signataire, "TYPE_DOC": type_labels.get(signature_log.type_document, "Document"), "NUMERO": signature_log.document_id, "DATE_SIGNATURE": datetime.now().strftime("%d/%m/%Y à %H:%M"), "TRANSACTION_ID": transaction_id, "CONTACT_EMAIL": settings.smtp_from, } sujet = template["sujet"] corps = template["corps_html"] for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) email_log = EmailLog( id=str(uuid.uuid4()), destinataire=signature_log.email_signataire, sujet=sujet, corps_html=corps, document_ids=signature_log.document_id, type_document=signature_log.type_document, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) email_queue.enqueue(email_log.id) logger.info( f" Email de confirmation envoyé: {signature_log.email_signataire}" ) elif event_type == "transaction.refused": signature_log.statut = StatutSignatureEnum.REFUSE logger.warning(f"Signature refusée: {signature_log.document_id}") elif event_type == "transaction.expired": signature_log.statut = StatutSignatureEnum.EXPIRE logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}") await session.commit() return { "status": "processed", "event": event_type, "transaction_id": transaction_id, } except Exception as e: logger.error(f"Erreur webhook Universign: {e}") return {"status": "error", "message": str(e)} @app.get("/admin/signatures/relances-auto", tags=["Admin"]) async def relancer_signatures_automatique(session: AsyncSession = Depends(get_session)): try: from datetime import timedelta date_limite = datetime.now() - timedelta(days=7) query = select(SignatureLog).where( SignatureLog.statut.in_( [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] ), SignatureLog.date_envoi < date_limite, SignatureLog.nb_relances < 3, # Max 3 relances ) result = await session.execute(query) signatures_a_relancer = result.scalars().all() nb_relances = 0 for signature in signatures_a_relancer: try: nb_jours = (datetime.now() - signature.date_envoi).days jours_restants = 30 - nb_jours # Lien expire après 30 jours if jours_restants <= 0: signature.statut = StatutSignatureEnum.EXPIRE continue template = templates_signature_email["relance_signature"] type_labels = { 0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir", } variables = { "NOM_SIGNATAIRE": signature.nom_signataire, "TYPE_DOC": type_labels.get(signature.type_document, "Document"), "NUMERO": signature.document_id, "NB_JOURS": str(nb_jours), "JOURS_RESTANTS": str(jours_restants), "SIGNER_URL": signature.signer_url, "CONTACT_EMAIL": settings.smtp_from, } sujet = template["sujet"] corps = template["corps_html"] for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) email_log = EmailLog( id=str(uuid.uuid4()), destinataire=signature.email_signataire, sujet=sujet, corps_html=corps, document_ids=signature.document_id, type_document=signature.type_document, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) email_queue.enqueue(email_log.id) signature.est_relance = True signature.nb_relances = (signature.nb_relances or 0) + 1 nb_relances += 1 logger.info( f" Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" ) except Exception as e: logger.error(f"Erreur relance signature {signature.id}: {e}") continue await session.commit() return { "success": True, "signatures_verifiees": len(signatures_a_relancer), "relances_envoyees": nb_relances, "message": f"{nb_relances} email(s) de relance envoyé(s)", } except Exception as e: logger.error(f"Erreur relances automatiques: {e}") raise HTTPException(500, str(e)) @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): try: async with async_session_factory() as session: query = select(SignatureLog).where(SignatureLog.document_id == docId) result = await session.execute(query) signature_log = result.scalar_one_or_none() if not signature_log: raise HTTPException(404, "Signature introuvable") statut = await universign_statut(signature_log.transaction_id) return { "doc_id": docId, "statut": statut["statut"], "date_signature": statut.get("date_signature"), } except HTTPException: raise except Exception as e: logger.error(f"Erreur statut signature: {e}") raise HTTPException(500, str(e)) @app.get("/signatures", tags=["Signatures"]) async def lister_signatures( statut: Optional[StatutSignature] = Query(None), limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) if statut: statut_db = StatutSignatureEnum[statut.value] query = query.where(SignatureLog.statut == statut_db) query = query.limit(limit) result = await session.execute(query) signatures = result.scalars().all() return [ { "id": sig.id, "document_id": sig.document_id, "type_document": sig.type_document.value, "transaction_id": sig.transaction_id, "signer_url": sig.signer_url, "email_signataire": sig.email_signataire, "nom_signataire": sig.nom_signataire, "statut": sig.statut.value, "date_envoi": sig.date_envoi.isoformat() if sig.date_envoi else None, "date_signature": ( sig.date_signature.isoformat() if sig.date_signature else None ), "est_relance": sig.est_relance, "nb_relances": sig.nb_relances or 0, } for sig in signatures ] @app.get("/signatures/{transaction_id}/status", tags=["Signatures"]) async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id) result = await session.execute(query) signature_log = result.scalar_one_or_none() if not signature_log: raise HTTPException(404, f"Transaction {transaction_id} introuvable") statut_universign = await universign_statut(transaction_id) if statut_universign.get("statut") != "ERREUR": statut_map = { "EN_ATTENTE": StatutSignatureEnum.EN_ATTENTE, "ENVOYE": StatutSignatureEnum.ENVOYE, "SIGNE": StatutSignatureEnum.SIGNE, "REFUSE": StatutSignatureEnum.REFUSE, "EXPIRE": StatutSignatureEnum.EXPIRE, } nouveau_statut = statut_map.get( statut_universign["statut"], StatutSignatureEnum.EN_ATTENTE ) signature_log.statut = nouveau_statut if statut_universign.get("date_signature"): signature_log.date_signature = datetime.fromisoformat( statut_universign["date_signature"].replace("Z", "+00:00") ) await session.commit() return { "transaction_id": transaction_id, "document_id": signature_log.document_id, "statut": signature_log.statut.value, "email_signataire": signature_log.email_signataire, "date_envoi": ( signature_log.date_envoi.isoformat() if signature_log.date_envoi else None ), "date_signature": ( signature_log.date_signature.isoformat() if signature_log.date_signature else None ), "signer_url": signature_log.signer_url, } @app.post("/signatures/refresh-all", tags=["Signatures"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): query = select(SignatureLog).where( SignatureLog.statut.in_( [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] ) ) result = await session.execute(query) signatures = result.scalars().all() nb_mises_a_jour = 0 for sig in signatures: try: statut_universign = await universign_statut(sig.transaction_id) if statut_universign.get("statut") != "ERREUR": statut_map = { "SIGNE": StatutSignatureEnum.SIGNE, "REFUSE": StatutSignatureEnum.REFUSE, "EXPIRE": StatutSignatureEnum.EXPIRE, } nouveau = statut_map.get(statut_universign["statut"]) if nouveau and nouveau != sig.statut: sig.statut = nouveau if statut_universign.get("date_signature"): sig.date_signature = datetime.fromisoformat( statut_universign["date_signature"].replace("Z", "+00:00") ) nb_mises_a_jour += 1 except Exception as e: logger.error(f"Erreur refresh signature {sig.transaction_id}: {e}") continue await session.commit() return { "success": True, "nb_signatures_verifiees": len(signatures), "nb_mises_a_jour": nb_mises_a_jour, } @app.post("/devis/{id}/signer", tags=["Devis"]) async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): try: devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) resultat = await universign_envoyer( id, pdf_bytes, request.email_signataire, request.nom_signataire ) if "error" in resultat: raise HTTPException(500, f"Erreur Universign: {resultat['error']}") signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, type_document=TypeDocument.DEVIS, transaction_id=resultat["transaction_id"], signer_url=resultat["signer_url"], email_signataire=request.email_signataire, nom_signataire=request.nom_signataire, statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), ) session.add(signature_log) await session.commit() sage_client.mettre_a_jour_champ_libre( id, TypeDocument.DEVIS, "UniversignID", resultat["transaction_id"] ) return { "success": True, "devis_id": id, "transaction_id": resultat["transaction_id"], "signer_url": resultat["signer_url"], } except HTTPException: raise except Exception as e: logger.error(f"Erreur envoi signature: {e}") raise HTTPException(500, str(e)) class EmailBatchRequest(BaseModel): destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) sujet: str = Field(..., min_length=1, max_length=500) corps_html: str = Field(..., min_length=1) document_ids: Optional[List[str]] = None type_document: Optional[TypeDocument] = None @app.post("/emails/send-batch", tags=["Emails"]) async def envoyer_emails_lot( batch: EmailBatchRequest, session: AsyncSession = Depends(get_session) ): resultats = [] for destinataire in batch.destinataires: email_log = EmailLog( id=str(uuid.uuid4()), destinataire=destinataire, sujet=batch.sujet, corps_html=batch.corps_html, document_ids=",".join(batch.document_ids) if batch.document_ids else None, type_document=batch.type_document, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.flush() email_queue.enqueue(email_log.id) resultats.append( { "destinataire": destinataire, "log_id": email_log.id, "statut": "EN_ATTENTE", } ) await session.commit() nb_documents = len(batch.document_ids) if batch.document_ids else 0 logger.info( f"{len(batch.destinataires)} emails mis en file avec {nb_documents} docs" ) return { "total": len(batch.destinataires), "succes": len(batch.destinataires), "documents_attaches": nb_documents, "details": resultats, } @app.post( "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] ) async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), ): try: remise_max = sage_client.lire_remise_max_client(client_id) autorisee = remise_pourcentage <= remise_max if not autorisee: message = f"Remise trop élevée (max autorisé: {remise_max}%)" logger.warning( f"Remise refusée pour {client_id}: {remise_pourcentage}% > {remise_max}%" ) else: message = "Remise autorisée" return BaremeRemiseResponse( client_id=client_id, remise_max_autorisee=remise_max, remise_demandee=remise_pourcentage, autorisee=autorisee, message=message, ) except Exception as e: logger.error(f"Erreur validation remise: {e}") raise HTTPException(500, str(e)) @app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): try: devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") contact = sage_client.lire_contact_client(devis["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) resultat = await universign_envoyer( id, pdf_bytes, contact["email"], contact["nom"] or contact["client_intitule"], ) if "error" in resultat: raise HTTPException(500, resultat["error"]) signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, type_document=TypeDocument.DEVIS, transaction_id=resultat["transaction_id"], signer_url=resultat["signer_url"], email_signataire=contact["email"], nom_signataire=contact["nom"] or contact["client_intitule"], statut=StatutSignatureEnum.ENVOYE, date_envoi=datetime.now(), est_relance=True, nb_relances=1, ) session.add(signature_log) await session.commit() return { "success": True, "devis_id": id, "transaction_id": resultat["transaction_id"], "message": "Relance signature envoyée", } except HTTPException: raise except Exception as e: logger.error(f"Erreur relance: {e}") raise HTTPException(500, str(e)) class ContactClientResponse(BaseModel): client_code: str client_intitule: str email: Optional[str] nom: Optional[str] telephone: Optional[str] peut_etre_relance: bool @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): try: devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") contact = sage_client.lire_contact_client(devis["client_code"]) if not contact: raise HTTPException( 404, f"Contact introuvable pour client {devis['client_code']}" ) peut_relancer = bool(contact.get("email")) return ContactClientResponse(**contact, peut_etre_relance=peut_relancer) except HTTPException: raise except Exception as e: logger.error(f"Erreur récupération contact: {e}") raise HTTPException(500, str(e)) @app.get("/factures", tags=["Factures"]) async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): try: factures = sage_client.lister_factures(limit=limit, statut=statut) return factures except Exception as e: logger.error(f"Erreur liste factures: {e}") raise HTTPException(500, str(e)) @app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail(numero: str): try: facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {numero} introuvable") return {"success": True, "data": facture} except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture facture {numero}: {e}") raise HTTPException(500, str(e)) class RelanceFactureRequest(BaseModel): doc_id: str message_personnalise: Optional[str] = None @app.post("/factures", status_code=201, tags=["Factures"]) async def creer_facture( facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) ): try: facture_data = { "client_id": facture.client_id, "date_facture": ( facture.date_facture.isoformat() if facture.date_facture else None ), "date_livraison": ( facture.date_livraison.isoformat() if facture.date_livraison else None ), "reference": facture.reference, "lignes": [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in facture.lignes ], } resultat = sage_client.creer_facture(facture_data) logger.info(f"Facture créée: {resultat.get('numero_facture')}") return { "success": True, "message": "Facture créée avec succès", "data": { "numero_facture": resultat["numero_facture"], "client_id": facture.client_id, "date_facture": resultat["date_facture"], "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], "reference": facture.reference, }, } except HTTPException: raise except Exception as e: logger.error(f"Erreur création facture: {e}") raise HTTPException(500, str(e)) @app.put("/factures/{id}", tags=["Factures"]) async def modifier_facture( id: str, facture_update: FactureUpdateRequest, session: AsyncSession = Depends(get_session), ): try: update_data = {} if facture_update.date_facture: update_data["date_facture"] = facture_update.date_facture.isoformat() if facture_update.lignes is not None: update_data["lignes"] = [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in facture_update.lignes ] if facture_update.statut is not None: update_data["statut"] = facture_update.statut if facture_update.reference is not None: update_data["reference"] = facture_update.reference resultat = sage_client.modifier_facture(id, update_data) logger.info(f"Facture {id} modifiée avec succès") return { "success": True, "message": f"Facture {id} modifiée avec succès", "facture": resultat, } except HTTPException: raise except Exception as e: logger.error(f"Erreur modification facture {id}: {e}") raise HTTPException(500, str(e)) templates_email_db = { "relance_facture": { "id": "relance_facture", "nom": "Relance Facture", "sujet": "Rappel - Facture {{DO_Piece}}", "corps_html": """

Bonjour {{CT_Intitule}},

La facture {{DO_Piece}} du {{DO_Date}} d'un montant de {{DO_TotalTTC}}€ TTC reste impayée.

Merci de régulariser dans les meilleurs délais.

Cordialement,

""", "variables_disponibles": [ "DO_Piece", "DO_Date", "CT_Intitule", "DO_TotalHT", "DO_TotalTTC", ], } } @app.post("/factures/{id}/relancer", tags=["Factures"]) async def relancer_facture( id: str, relance: RelanceFactureRequest, session: AsyncSession = Depends(get_session), ): try: facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {id} introuvable") contact = sage_client.lire_contact_client(facture["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") template = templates_email_db["relance_facture"] variables = { "DO_Piece": facture.get("numero", id), "DO_Date": str(facture.get("date", "")), "CT_Intitule": facture.get("client_intitule", ""), "DO_TotalHT": f"{facture.get('total_ht', 0):.2f}", "DO_TotalTTC": f"{facture.get('total_ttc', 0):.2f}", } sujet = template["sujet"] corps = relance.message_personnalise or template["corps_html"] for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", valeur) corps = corps.replace(f"{{{{{var}}}}}", valeur) email_log = EmailLog( id=str(uuid.uuid4()), destinataire=contact["email"], sujet=sujet, corps_html=corps, document_ids=id, type_document=TypeDocument.FACTURE, statut=StatutEmailEnum.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.flush() email_queue.enqueue(email_log.id) sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) await session.commit() logger.info(f"Relance facture: {id} → {contact['email']}") return { "success": True, "facture_id": id, "email_log_id": email_log.id, "destinataire": contact["email"], } except HTTPException: raise except Exception as e: logger.error(f"Erreur relance facture: {e}") raise HTTPException(500, str(e)) @app.get("/emails/logs", tags=["Emails"]) async def journal_emails( statut: Optional[StatutEmail] = Query(None), destinataire: Optional[str] = Query(None), limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), ): query = select(EmailLog) if statut: query = query.where(EmailLog.statut == StatutEmailEnum[statut.value]) if destinataire: query = query.where(EmailLog.destinataire.contains(destinataire)) query = query.order_by(EmailLog.date_creation.desc()).limit(limit) result = await session.execute(query) logs = result.scalars().all() return [ { "id": log.id, "destinataire": log.destinataire, "sujet": log.sujet, "statut": log.statut.value, "date_creation": log.date_creation.isoformat(), "date_envoi": log.date_envoi.isoformat() if log.date_envoi else None, "nb_tentatives": log.nb_tentatives, "derniere_erreur": log.derniere_erreur, "document_ids": log.document_ids, } for log in logs ] @app.get("/emails/logs/export", tags=["Emails"]) async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), ): query = select(EmailLog) if statut: query = query.where(EmailLog.statut == StatutEmailEnum[statut.value]) query = query.order_by(EmailLog.date_creation.desc()) result = await session.execute(query) logs = result.scalars().all() output = io.StringIO() writer = csv.writer(output) writer.writerow( [ "ID", "Destinataire", "Sujet", "Statut", "Date Création", "Date Envoi", "Nb Tentatives", "Erreur", "Documents", ] ) for log in logs: writer.writerow( [ log.id, log.destinataire, log.sujet, log.statut.value, log.date_creation.isoformat(), log.date_envoi.isoformat() if log.date_envoi else "", log.nb_tentatives, log.derniere_erreur or "", log.document_ids or "", ] ) output.seek(0) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": f"attachment; filename=emails_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" }, ) class TemplateEmail(BaseModel): id: Optional[str] = None nom: str sujet: str corps_html: str variables_disponibles: List[str] = [] class TemplatePreviewRequest(BaseModel): template_id: str document_id: str type_document: TypeDocument @app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) async def lister_templates(): return [TemplateEmail(**template) for template in templates_email_db.values()] @app.get( "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def lire_template(template_id: str): if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") return TemplateEmail(**templates_email_db[template_id]) @app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) async def creer_template(template: TemplateEmail): template_id = str(uuid.uuid4()) templates_email_db[template_id] = { "id": template_id, "nom": template.nom, "sujet": template.sujet, "corps_html": template.corps_html, "variables_disponibles": template.variables_disponibles, } logger.info(f"Template créé: {template_id}") return TemplateEmail(id=template_id, **template.dict()) @app.put( "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def modifier_template(template_id: str, template: TemplateEmail): if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") if template_id in ["relance_devis", "relance_facture"]: raise HTTPException(400, "Les templates système ne peuvent pas être modifiés") templates_email_db[template_id] = { "id": template_id, "nom": template.nom, "sujet": template.sujet, "corps_html": template.corps_html, "variables_disponibles": template.variables_disponibles, } logger.info(f"Template modifié: {template_id}") return TemplateEmail(id=template_id, **template.dict()) @app.delete("/templates/emails/{template_id}", tags=["Emails"]) async def supprimer_template(template_id: str): if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") if template_id in ["relance_devis", "relance_facture"]: raise HTTPException(400, "Les templates système ne peuvent pas être supprimés") del templates_email_db[template_id] logger.info(f"Template supprimé: {template_id}") return {"success": True, "message": f"Template {template_id} supprimé"} @app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email(preview: TemplatePreviewRequest): if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") template = templates_email_db[preview.template_id] doc = sage_client.lire_document(preview.document_id, preview.type_document) if not doc: raise HTTPException(404, f"Document {preview.document_id} introuvable") variables = { "DO_Piece": doc.get("numero", preview.document_id), "DO_Date": str(doc.get("date", "")), "CT_Intitule": doc.get("client_intitule", ""), "DO_TotalHT": f"{doc.get('total_ht', 0):.2f}", "DO_TotalTTC": f"{doc.get('total_ttc', 0):.2f}", } sujet_preview = template["sujet"] corps_preview = template["corps_html"] for var, valeur in variables.items(): sujet_preview = sujet_preview.replace(f"{{{{{var}}}}}", valeur) corps_preview = corps_preview.replace(f"{{{{{var}}}}}", valeur) return { "template_id": preview.template_id, "document_id": preview.document_id, "sujet": sujet_preview, "corps_html": corps_preview, "variables_utilisees": variables, } @app.get("/health", tags=["System"]) async def health_check(): gateway_health = sage_client.health() return { "status": "healthy", "sage_gateway": gateway_health, "email_queue": { "running": email_queue.running, "workers": len(email_queue.workers), "queue_size": email_queue.queue.qsize(), }, "timestamp": datetime.now().isoformat(), } @app.get("/", tags=["System"]) async def root(): return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", "documentation": "/docs", "health": "/health", } @app.get("/admin/cache/info", tags=["Admin"]) async def info_cache(): try: cache_info = sage_client.get_cache_info() return cache_info except Exception as e: logger.error(f"Erreur info cache: {e}") raise HTTPException(500, str(e)) @app.get("/admin/queue/status", tags=["Admin"]) async def statut_queue(): return { "queue_size": email_queue.queue.qsize(), "workers": len(email_queue.workers), "running": email_queue.running, } @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects(query: Optional[str] = Query(None)): try: prospects = sage_client.lister_prospects(filtre=query or "") return prospects except Exception as e: logger.error(f"Erreur recherche prospects: {e}") raise HTTPException(500, str(e)) @app.get("/prospects/{code}", tags=["Prospects"]) async def lire_prospect(code: str): try: prospect = sage_client.lire_prospect(code) if not prospect: raise HTTPException(404, f"Prospect {code} introuvable") return prospect except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture prospect: {e}") raise HTTPException(500, str(e)) @app.get("/fournisseurs", response_model=List[FournisseurDetails], tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): try: fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") logger.info(f"{len(fournisseurs)} fournisseurs") if len(fournisseurs) == 0: logger.warning("Aucun fournisseur retourné - vérifier la gateway Windows") return [FournisseurDetails(**f) for f in fournisseurs] except Exception as e: logger.error(f"Erreur recherche fournisseurs: {e}") raise HTTPException(500, str(e)) @app.post("/fournisseurs", status_code=201, tags=["Fournisseurs"]) async def ajouter_fournisseur( fournisseur: FournisseurCreateAPIRequest, session: AsyncSession = Depends(get_session), ): try: nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) logger.info(f"Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") return { "success": True, "message": "Fournisseur créé avec succès", "data": nouveau_fournisseur, } except ValueError as e: logger.warning(f"Erreur métier création fournisseur: {e}") raise HTTPException(400, str(e)) except Exception as e: logger.error(f"Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) @app.put("/fournisseurs/{code}", response_model=FournisseurDetails, tags=["Fournisseurs"]) async def modifier_fournisseur( code: str, fournisseur_update: FournisseurUpdateRequest, session: AsyncSession = Depends(get_session), ): try: resultat = sage_client.modifier_fournisseur( code, fournisseur_update.dict(exclude_none=True) ) logger.info(f"Fournisseur {code} modifié avec succès") return FournisseurDetails(**resultat) except ValueError as e: logger.warning(f"Erreur métier modification fournisseur {code}: {e}") raise HTTPException(404, str(e)) except Exception as e: logger.error(f"Erreur technique modification fournisseur {code}: {e}") raise HTTPException(500, str(e)) @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur(code: str): try: fournisseur = sage_client.lire_fournisseur(code) if not fournisseur: raise HTTPException(404, f"Fournisseur {code} introuvable") return fournisseur except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture fournisseur: {e}") raise HTTPException(500, str(e)) @app.get("/avoirs", tags=["Avoirs"]) async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): try: avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) return avoirs except Exception as e: logger.error(f"Erreur liste avoirs: {e}") raise HTTPException(500, str(e)) @app.get("/avoirs/{numero}", tags=["Avoirs"]) async def lire_avoir(numero: str): try: avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) if not avoir: raise HTTPException(404, f"Avoir {numero} introuvable") return avoir except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture avoir: {e}") raise HTTPException(500, str(e)) @app.post("/avoirs", status_code=201, tags=["Avoirs"]) async def creer_avoir( avoir: AvoirCreateRequest, session: AsyncSession = Depends(get_session) ): try: avoir_data = { "client_id": avoir.client_id, "date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None), "date_livraison": ( avoir.date_livraison.isoformat() if avoir.date_livraison else None ), "reference": avoir.reference, "lignes": [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in avoir.lignes ], } resultat = sage_client.creer_avoir(avoir_data) logger.info(f"Avoir créé: {resultat.get('numero_avoir')}") return { "success": True, "message": "Avoir créé avec succès", "data": { "numero_avoir": resultat["numero_avoir"], "client_id": avoir.client_id, "date_avoir": resultat["date_avoir"], "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], "reference": avoir.reference, }, } except HTTPException: raise except Exception as e: logger.error(f"Erreur création avoir: {e}") raise HTTPException(500, str(e)) @app.put("/avoirs/{id}", tags=["Avoirs"]) async def modifier_avoir( id: str, avoir_update: AvoirUpdateRequest, session: AsyncSession = Depends(get_session), ): try: update_data = {} if avoir_update.date_avoir: update_data["date_avoir"] = avoir_update.date_avoir.isoformat() if avoir_update.lignes is not None: update_data["lignes"] = [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in avoir_update.lignes ] if avoir_update.statut is not None: update_data["statut"] = avoir_update.statut if avoir_update.reference is not None: update_data["reference"] = avoir_update.reference resultat = sage_client.modifier_avoir(id, update_data) logger.info(f"Avoir {id} modifié avec succès") return { "success": True, "message": f"Avoir {id} modifié avec succès", "avoir": resultat, } except HTTPException: raise except Exception as e: logger.error(f"Erreur modification avoir {id}: {e}") raise HTTPException(500, str(e)) @app.get("/livraisons", tags=["Livraisons"]) async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): try: livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) return livraisons except Exception as e: logger.error(f"Erreur liste livraisons: {e}") raise HTTPException(500, str(e)) @app.get("/livraisons/{numero}", tags=["Livraisons"]) async def lire_livraison(numero: str): try: livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) if not livraison: raise HTTPException(404, f"Livraison {numero} introuvable") return livraison except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture livraison: {e}") raise HTTPException(500, str(e)) @app.post("/livraisons", status_code=201, tags=["Livraisons"]) async def creer_livraison( livraison: LivraisonCreateRequest, session: AsyncSession = Depends(get_session) ): try: livraison_data = { "client_id": livraison.client_id, "date_livraison": ( livraison.date_livraison.isoformat() if livraison.date_livraison else None ), "date_livraison_prevue": ( livraison.date_livraison_prevue.isoformat() if livraison.date_livraison_prevue else None ), "reference": livraison.reference, "lignes": [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in livraison.lignes ], } resultat = sage_client.creer_livraison(livraison_data) logger.info(f"Livraison créée: {resultat.get('numero_livraison')}") return { "success": True, "message": "Livraison créée avec succès", "data": { "numero_livraison": resultat["numero_livraison"], "client_id": livraison.client_id, "date_livraison": resultat["date_livraison"], "total_ht": resultat["total_ht"], "total_ttc": resultat["total_ttc"], "nb_lignes": resultat["nb_lignes"], "reference": livraison.reference, }, } except HTTPException: raise except Exception as e: logger.error(f"Erreur création livraison: {e}") raise HTTPException(500, str(e)) @app.put("/livraisons/{id}", tags=["Livraisons"]) async def modifier_livraison( id: str, livraison_update: LivraisonUpdateRequest, session: AsyncSession = Depends(get_session), ): try: update_data = {} if livraison_update.date_livraison: update_data["date_livraison"] = livraison_update.date_livraison.isoformat() if livraison_update.lignes is not None: update_data["lignes"] = [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "remise_pourcentage": ligne.remise_pourcentage, } for ligne in livraison_update.lignes ] if livraison_update.statut is not None: update_data["statut"] = livraison_update.statut if livraison_update.reference is not None: update_data["reference"] = livraison_update.reference resultat = sage_client.modifier_livraison(id, update_data) logger.info(f"Livraison {id} modifiée avec succès") return { "success": True, "message": f"Livraison {id} modifiée avec succès", "livraison": resultat, } except HTTPException: raise except Exception as e: logger.error(f"Erreur modification livraison {id}: {e}") raise HTTPException(500, str(e)) @app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): try: resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.BON_LIVRAISON, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.FACTURE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), succes=True, ) session.add(workflow_log) await session.commit() logger.info( f"Transformation: Livraison {id} → Facture {resultat['document_cible']}" ) return { "success": True, "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], } except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) @app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session) ): try: devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") statut_devis = devis_existant.get("statut", 0) if statut_devis == 5: raise HTTPException( 400, f"Le devis {id} a déjà été transformé (statut=5). " f"Vérifiez les documents déjà créés depuis ce devis.", ) resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.DEVIS, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.FACTURE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), succes=True, ) session.add(workflow_log) await session.commit() logger.info( f"Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}" ) return { "success": True, "workflow": "devis_to_facture_direct", "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], "statut_devis_mis_a_jour": True, "message": f"Facture {resultat['document_cible']} créée directement depuis le devis {id}", } except HTTPException: raise except Exception as e: logger.error(f"Erreur transformation devis→facture: {e}", exc_info=True) raise HTTPException(500, str(e)) @app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session) ): try: commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") statut_commande = commande_existante.get("statut", 0) if statut_commande == 5: raise HTTPException( 400, f"La commande {id} a déjà été transformée (statut=5). " f"Un bon de livraison existe probablement déjà.", ) if statut_commande == 6: raise HTTPException( 400, f"La commande {id} est annulée (statut=6) et ne peut pas être transformée.", ) resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 ) workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.BON_COMMANDE, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.BON_LIVRAISON, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), succes=True, ) session.add(workflow_log) await session.commit() logger.info( f"Transformation: Commande {id} → Livraison {resultat['document_cible']}" ) return { "success": True, "workflow": "commande_to_livraison", "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], "message": f"Bon de livraison {resultat['document_cible']} créé depuis la commande {id}", "next_step": f"Utilisez /workflow/livraison/{resultat['document_cible']}/to-facture pour créer la facture", } except HTTPException: raise except Exception as e: logger.error(f"Erreur transformation commande→livraison: {e}", exc_info=True) 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: 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}") resultat = sage_client.creer_famille(famille_data) logger.info(f"Famille créée: {resultat.get('code')}") return FamilleResponse(**resultat) except ValueError as e: 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: 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: entree_data = entree.dict() if entree_data.get("date_entree"): entree_data["date_entree"] = entree_data["date_entree"].isoformat() logger.info(f"Création entrée stock: {len(entree.lignes)} ligne(s)") 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: sortie_data = sortie.dict() if sortie_data.get("date_sortie"): sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") 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), limit: int = Query(100, le=1000), role: Optional[str] = Query(None), verified_only: bool = Query(False), ): from database import User from sqlalchemy import select try: query = select(User) if role: query = query.where(User.role == role) if verified_only: query = query.where(User.is_verified) query = query.order_by(User.created_at.desc()).limit(limit) result = await session.execute(query) users = result.scalars().all() users_response = [] for user in users: users_response.append( UserResponse( id=user.id, email=user.email, nom=user.nom, prenom=user.prenom, role=user.role, is_verified=user.is_verified, is_active=user.is_active, created_at=user.created_at.isoformat() if user.created_at else "", last_login=user.last_login.isoformat() if user.last_login else None, failed_login_attempts=user.failed_login_attempts or 0, ) ) logger.info( f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)" ) return users_response except Exception as e: logger.error(f"Erreur liste utilisateurs: {e}") raise HTTPException(500, str(e)) @app.get("/debug/users/stats", tags=["Debug"]) async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)): from database import User from sqlalchemy import select, func try: total_query = select(func.count(User.id)) total_result = await session.execute(total_query) total = total_result.scalar() verified_query = select(func.count(User.id)).where(User.is_verified) verified_result = await session.execute(verified_query) verified = verified_result.scalar() active_query = select(func.count(User.id)).where(User.is_active) active_result = await session.execute(active_query) active = active_result.scalar() roles_query = select(User.role, func.count(User.id)).group_by(User.role) roles_result = await session.execute(roles_query) roles_stats = {role: count for role, count in roles_result.all()} return { "total_utilisateurs": total, "utilisateurs_verifies": verified, "utilisateurs_actifs": active, "utilisateurs_non_verifies": total - verified, "repartition_roles": roles_stats, "taux_verification": f"{(verified/total*100):.1f}%" if total > 0 else "0%", } except Exception as e: logger.error(f"Erreur stats utilisateurs: {e}") raise HTTPException(500, str(e)) @app.get("/modeles", tags=["PDF Sage-Like"]) async def get_modeles_disponibles(): """Liste tous les modèles PDF disponibles""" try: modeles = sage_client.lister_modeles_disponibles() return modeles except Exception as e: logger.error(f"Erreur listage modèles: {e}") raise HTTPException(500, str(e)) @app.get("/documents/{numero}/pdf", tags=["PDF Sage-Like"]) async def get_document_pdf( numero: str, type_doc: int = Query(..., description="0=devis, 60=facture, etc."), modele: str = Query( None, description="Nom du modèle (ex: 'Facture client logo.bgc')" ), download: bool = Query(False, description="Télécharger au lieu d'afficher"), ): try: pdf_bytes = sage_client.generer_pdf_document( numero=numero, type_doc=type_doc, modele=modele, base64_encode=False, # On veut les bytes bruts ) from fastapi.responses import Response disposition = "attachment" if download else "inline" filename = f"{numero}.pdf" return Response( content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'{disposition}; filename="{filename}"'}, ) except Exception as e: logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) @app.post("/clients/{numero}/contacts", response_model=Contact, tags=["Contacts"]) async def creer_contact(numero: str, contact: ContactCreate): try: try: sage_client.lire_client(numero) except: raise HTTPException(404, f"Client {numero} non trouvé") if contact.numero != numero: contact.numero = numero resultat = sage_client.creer_contact(contact.dict()) return Contact(**resultat) except HTTPException: raise except Exception as e: logger.error(f"Erreur création contact: {e}") raise HTTPException(500, str(e)) @app.get("/clients/{numero}/contacts", response_model=List[Contact], tags=["Contacts"]) async def lister_contacts_client(numero: str): try: contacts = sage_client.lister_contacts(numero) return [Contact(**c) for c in contacts] except Exception as e: logger.error(f"Erreur liste contacts: {e}") raise HTTPException(500, str(e)) @app.get("/clients/{numero}/contacts/{contact_numero}", response_model=Contact, tags=["Contacts"]) async def obtenir_contact(numero: str, contact_numero: int): try: contact = sage_client.obtenir_contact(numero, contact_numero) if not contact: raise HTTPException(404, f"Contact {contact_numero} non trouvé pour client {numero}") return Contact(**contact) except HTTPException: raise except Exception as e: logger.error(f"Erreur récupération contact: {e}") raise HTTPException(500, str(e)) @app.put("/clients/{numero}/contacts/{contact_numero}", response_model=Contact, tags=["Contacts"]) async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpdate): try: contact_existant = sage_client.obtenir_contact(numero, contact_numero) if not contact_existant: raise HTTPException(404, f"Contact {contact_numero} non trouvé") updates = {k: v for k, v in contact.dict().items() if v is not None} if not updates: raise HTTPException(400, "Aucune modification fournie") resultat = sage_client.modifier_contact(numero, contact_numero, updates) return Contact(**resultat) except HTTPException: raise except Exception as e: logger.error(f"Erreur modification contact: {e}") raise HTTPException(500, str(e)) @app.delete("/clients/{numero}/contacts/{contact_numero}", tags=["Contacts"]) async def supprimer_contact(numero: str, contact_numero: int): try: resultat = sage_client.supprimer_contact(numero, contact_numero) return {"success": True, "message": f"Contact {contact_numero} supprimé"} except Exception as e: logger.error(f"Erreur suppression contact: {e}") raise HTTPException(500, str(e)) @app.post("/clients/{numero}/contacts/{contact_numero}/definir-defaut", tags=["Contacts"]) async def definir_contact_defaut(numero: str, contact_numero: int): try: resultat = sage_client.definir_contact_defaut(numero, contact_numero) return { "success": True, "message": f"Contact {contact_numero} défini comme contact par défaut", "data": resultat } except Exception as e: logger.error(f"Erreur définition contact par défaut: {e}") raise HTTPException(500, str(e)) @app.get("/tiers", response_model=List[TiersDetails], tags=["Tiers"]) async def obtenir_tiers( type_tiers: Optional[TypeTiers] = Query( None, description="Filtre par type: client, fournisseur, prospect, ou all" ), query: Optional[str] = Query( None, description="Recherche sur code ou intitulé" ) ): try: tiers = sage_client.lister_tiers( type_tiers=type_tiers.value if type_tiers else None, filtre=query or "" ) return [TiersDetails(**t) for t in tiers] except Exception as e: logger.error(f" Erreur recherche tiers: {e}") raise HTTPException(500, str(e)) @app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"]) async def lire_tiers_detail(code: str): try: tiers = sage_client.lire_tiers(code) if not tiers: raise HTTPException(404, f"Tiers {code} introuvable") return TiersDetails(**tiers) except HTTPException: raise except Exception as e: logger.error(f" Erreur lecture tiers {code}: {e}") raise HTTPException(500, str(e)) if __name__ == "__main__": uvicorn.run( "api:app", host=settings.api_host, port=settings.api_port, reload=settings.api_reload, )