From 7e7c274724474fe782fa4591538c443c09fad93a Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 26 Dec 2025 12:09:48 +0300 Subject: [PATCH] feat: Updated pydantic schema for create and update client function --- .gitignore | 4 +- api.py | 1307 +++++++++++++++++++++++++--------------------------- 2 files changed, 628 insertions(+), 683 deletions(-) diff --git a/.gitignore b/.gitignore index 3a36b60..b88f070 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ htmlcov/ .build/ dist/ -data/sage_dataven.db \ No newline at end of file +data/sage_dataven.db + +cleaner.py \ No newline at end of file diff --git a/api.py b/api.py index 0aeb0da..a3b9ce9 100644 --- a/api.py +++ b/api.py @@ -6,7 +6,6 @@ from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict from datetime import date, datetime from enum import Enum, IntEnum -from decimal import Decimal import uvicorn from contextlib import asynccontextmanager import uuid @@ -17,18 +16,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from routes.auth import router as auth_router -from core.dependencies import get_current_user, require_role - - -# Configuration logging -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__) - -# Imports locaux from config import settings from database import ( init_db, @@ -43,6 +30,13 @@ from database import ( from email_queue import email_queue from sage_client import sage_client +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 = [ { @@ -79,9 +73,7 @@ TAGS_METADATA = [ ] -# ===================================================== -# ENUMS -# ===================================================== + class TypeDocument(int, Enum): DEVIS = settings.SAGE_TYPE_DEVIS BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE @@ -119,9 +111,7 @@ class StatutEmail(str, Enum): BOUNCE = "BOUNCE" -# ===================================================== -# MODÈLES PYDANTIC -# ===================================================== + class ClientResponse(BaseModel): """Modèle de réponse client simplifié (pour listes)""" @@ -137,13 +127,11 @@ class ClientResponse(BaseModel): class ClientDetails(BaseModel): """Modèle de réponse client complet (pour GET /clients/{code})""" - # === IDENTIFICATION === numero: Optional[str] = Field(None, description="Code client (CT_Num)") intitule: Optional[str] = Field( None, description="Raison sociale ou Nom complet (CT_Intitule)" ) - # === TYPE DE TIERS === type_tiers: Optional[str] = Field( None, description="Type : 'client', 'prospect', 'fournisseur' ou 'client_fournisseur'", @@ -159,7 +147,6 @@ class ClientDetails(BaseModel): None, description="True si fournisseur (CT_Qualite=2 ou 3)" ) - # === TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === forme_juridique: Optional[str] = Field( None, description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier", @@ -171,13 +158,11 @@ class ClientDetails(BaseModel): None, description="True si particulier (pas de forme juridique)" ) - # === STATUT === est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") est_en_sommeil: Optional[bool] = Field( None, description="True si en sommeil (CT_Sommeil=1)" ) - # === IDENTITÉ (POUR PARTICULIERS) === civilite: Optional[str] = Field(None, description="M., Mme, Mlle (CT_Civilite)") nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)") prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)") @@ -185,12 +170,10 @@ class ClientDetails(BaseModel): None, description="Nom complet formaté : 'Civilité Prénom Nom'" ) - # === CONTACT === contact: Optional[str] = Field( None, description="Nom du contact principal (CT_Contact)" ) - # === ADRESSE === adresse: Optional[str] = Field(None, description="Adresse ligne 1") complement: Optional[str] = Field(None, description="Complément d'adresse") code_postal: Optional[str] = Field(None, description="Code postal") @@ -198,20 +181,17 @@ class ClientDetails(BaseModel): region: Optional[str] = Field(None, description="Région/État") pays: Optional[str] = Field(None, description="Pays") - # === TÉLÉCOMMUNICATIONS === telephone: Optional[str] = Field(None, description="Téléphone fixe") portable: Optional[str] = Field(None, description="Téléphone mobile") telecopie: Optional[str] = Field(None, description="Fax") email: Optional[str] = Field(None, description="Email principal") site_web: Optional[str] = Field(None, description="Site web") - # === INFORMATIONS JURIDIQUES (ENTREPRISES) === siret: Optional[str] = Field(None, description="N° SIRET (14 chiffres)") siren: Optional[str] = Field(None, description="N° SIREN (9 chiffres)") tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire") code_naf: Optional[str] = Field(None, description="Code NAF/APE") - # === INFORMATIONS COMMERCIALES === secteur: Optional[str] = Field(None, description="Secteur d'activité") effectif: Optional[int] = Field(None, description="Nombre d'employés") ca_annuel: Optional[float] = Field(None, description="Chiffre d'affaires annuel") @@ -220,7 +200,6 @@ class ClientDetails(BaseModel): ) commercial_nom: Optional[str] = Field(None, description="Nom du commercial") - # === CATÉGORIES === categorie_tarifaire: Optional[int] = Field( None, description="Catégorie tarifaire (N_CatTarif)" ) @@ -228,7 +207,6 @@ class ClientDetails(BaseModel): None, description="Catégorie comptable (N_CatCompta)" ) - # === INFORMATIONS FINANCIÈRES === encours_autorise: Optional[float] = Field( None, description="Encours maximum autorisé" ) @@ -237,7 +215,6 @@ class ClientDetails(BaseModel): ) compte_general: Optional[str] = Field(None, description="Compte général principal") - # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field( None, description="Date de dernière modification" @@ -271,23 +248,19 @@ class ArticleResponse(BaseModel): ENRICHI avec tous les champs disponibles """ - # === IDENTIFICATION === reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") designation_complementaire: Optional[str] = Field( None, description="Désignation complémentaire" ) - # === CODE EAN / CODE-BARRES === code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") code_barre: Optional[str] = Field(None, description="Code-barres (alias)") - # === PRIX === prix_vente: float = Field(..., description="Prix de vente HT") prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") prix_revient: Optional[float] = Field(None, description="Prix de revient") - # === STOCK === stock_reel: float = Field(..., description="Stock réel") stock_mini: Optional[float] = Field(None, description="Stock minimum") stock_maxi: Optional[float] = Field(None, description="Stock maximum") @@ -301,12 +274,10 @@ class ArticleResponse(BaseModel): None, description="Stock disponible (réel - réservé)" ) - # === DESCRIPTIONS === description: Optional[str] = Field( None, description="Description détaillée / Commentaire" ) - # === CLASSIFICATION === type_article: Optional[int] = Field( None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)" ) @@ -314,7 +285,6 @@ class ArticleResponse(BaseModel): famille_code: Optional[str] = Field(None, description="Code famille") famille_libelle: Optional[str] = Field(None, description="Libellé famille") - # === FOURNISSEUR PRINCIPAL === fournisseur_principal: Optional[str] = Field( None, description="Code fournisseur principal" ) @@ -322,23 +292,18 @@ class ArticleResponse(BaseModel): None, description="Nom fournisseur principal" ) - # === UNITÉS === unite_vente: Optional[str] = Field(None, description="Unité de vente") unite_achat: Optional[str] = Field(None, description="Unité d'achat") - # === CARACTÉRISTIQUES PHYSIQUES === poids: Optional[float] = Field(None, description="Poids (kg)") volume: Optional[float] = Field(None, description="Volume (m³)") - # === STATUT === est_actif: bool = Field(True, description="Article actif") en_sommeil: bool = Field(False, description="Article en sommeil") - # === TVA === tva_code: Optional[str] = Field(None, description="Code TVA") tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") - # === DATES === date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field( None, description="Date de dernière modification" @@ -410,154 +375,174 @@ class TypeTiers(IntEnum): AUTRE = 3 -class ClientCreateAPIRequest(BaseModel): - """ - Modèle complet pour la création d'un client Sage 100c - Noms alignés sur le frontend + mapping vers champs Sage - """ - - # ══════════════════════════════════════════════════════════════ - # IDENTIFICATION PRINCIPALE - # ══════════════════════════════════════════════════════════════ - intitule: str = Field(..., max_length=69, description="Nom du client (CT_Intitule)") - numero: Optional[str] = Field(None, max_length=17, description="Numéro client CT_Num (auto si vide)") - type_tiers: Optional[int] = Field(0, ge=0, le=3, description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre") - qualite: 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") - - # ══════════════════════════════════════════════════════════════ - # STATUTS & FLAGS - # ══════════════════════════════════════════════════════════════ - est_prospect: bool = Field(False, description="CT_Prospect") - est_actif: bool = Field(True, description="Inverse de CT_Sommeil") - est_en_sommeil: Optional[bool] = Field(None, description="CT_Sommeil (calculé depuis est_actif si None)") - - # ══════════════════════════════════════════════════════════════ - # INFORMATIONS ENTREPRISE / PERSONNE - # ══════════════════════════════════════════════════════════════ - est_entreprise: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct") - est_particulier: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct") - forme_juridique: Optional[str] = Field(None, max_length=33, description="CT_SvFormeJuri") - civilite: Optional[str] = Field(None, max_length=17, description="Stocké dans CT_Qualite ou champ libre") - nom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule") - prenom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule") - nom_complet: Optional[str] = Field(None, max_length=69, description="Calculé ou CT_Intitule") - - # ══════════════════════════════════════════════════════════════ - # ADRESSE PRINCIPALE - # ══════════════════════════════════════════════════════════════ - contact: Optional[str] = Field(None, max_length=35, description="CT_Contact") - adresse: Optional[str] = Field(None, max_length=35, description="CT_Adresse") - complement: Optional[str] = Field(None, max_length=35, description="CT_Complement") - code_postal: Optional[str] = Field(None, max_length=9, description="CT_CodePostal") - ville: Optional[str] = Field(None, max_length=35, description="CT_Ville") - region: Optional[str] = Field(None, max_length=25, description="CT_CodeRegion") - pays: Optional[str] = Field(None, max_length=35, description="CT_Pays") - - # ══════════════════════════════════════════════════════════════ - # CONTACT & COMMUNICATION - # ══════════════════════════════════════════════════════════════ - telephone: Optional[str] = Field(None, max_length=21, description="CT_Telephone") - portable: Optional[str] = Field(None, max_length=21, description="Stocké dans statistiques ou contact") - telecopie: Optional[str] = Field(None, max_length=21, description="CT_Telecopie") - email: Optional[str] = Field(None, max_length=69, description="CT_EMail") - site_web: Optional[str] = Field(None, max_length=69, description="CT_Site") - facebook: Optional[str] = Field(None, max_length=35, description="CT_Facebook") - linkedin: Optional[str] = Field(None, max_length=35, description="CT_LinkedIn") - - # ══════════════════════════════════════════════════════════════ - # IDENTIFIANTS LÉGAUX & FISCAUX - # ══════════════════════════════════════════════════════════════ - siret: Optional[str] = Field(None, max_length=15, description="CT_Siret (14-15 chars)") - siren: Optional[str] = Field(None, max_length=9, description="Extrait du SIRET") - tva_intra: Optional[str] = Field(None, max_length=25, description="CT_Identifiant") - code_naf: Optional[str] = Field(None, max_length=7, description="CT_Ape") - type_nif: Optional[int] = Field(None, ge=0, le=10, description="CT_TypeNIF") - - # ══════════════════════════════════════════════════════════════ - # BANQUE & DEVISE - # ══════════════════════════════════════════════════════════════ - banque_num: Optional[int] = Field(None, description="BT_Num (smallint)") - devise: Optional[int] = Field(0, description="N_Devise (0=EUR)") - - # ══════════════════════════════════════════════════════════════ - # CATÉGORIES & CLASSIFICATIONS COMMERCIALES - # ══════════════════════════════════════════════════════════════ - categorie_tarifaire: Optional[int] = Field(1, ge=0, description="N_CatTarif") - categorie_comptable: Optional[int] = Field(1, ge=0, description="N_CatCompta") - periode_reglement: Optional[int] = Field(1, ge=0, description="N_Period") - mode_expedition: Optional[int] = Field(1, ge=0, description="N_Expedition") - condition_livraison: Optional[int] = Field(1, ge=0, description="N_Condition") - niveau_risque: Optional[int] = Field(1, ge=0, description="N_Risque") - secteur: Optional[str] = Field(None, max_length=21, description="CT_Statistique01 ou champ libre") - - # ══════════════════════════════════════════════════════════════ - # TAUX PERSONNALISÉS - # ══════════════════════════════════════════════════════════════ - taux01: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux01") - taux02: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux02") - taux03: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux03") - taux04: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux04") - - # ══════════════════════════════════════════════════════════════ - # GESTION COMMERCIALE - # ══════════════════════════════════════════════════════════════ - encours_autorise: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Encours") - assurance_credit: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Assurance") - num_payeur: Optional[str] = Field(None, max_length=17, description="CT_NumPayeur") - langue: Optional[int] = Field(None, ge=0, description="CT_Langue") - langue_iso2: Optional[str] = Field(None, max_length=3, description="CT_LangueISO2") - commercial_code: Optional[int] = Field(None, description="CO_No (int)") - commercial_nom: Optional[str] = Field(None, description="Résolu depuis CO_No - non stocké") - effectif: Optional[str] = Field(None, max_length=11, description="CT_SvEffectif") - ca_annuel: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA") - - # ══════════════════════════════════════════════════════════════ - # COMPTABILITÉ - # ══════════════════════════════════════════════════════════════ - compte_general: Optional[str] = Field("411000", max_length=13, description="CG_NumPrinc") - - # ══════════════════════════════════════════════════════════════ - # PARAMÈTRES FACTURATION - # ══════════════════════════════════════════════════════════════ - type_facture: Optional[int] = Field(1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée") - bl_en_facture: Optional[int] = Field(None, ge=0, le=1, description="CT_BLFact") - saut_page: Optional[int] = Field(None, ge=0, le=1, description="CT_Saut") - lettrage_auto: Optional[bool] = Field(True, description="CT_Lettrage") - 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") - - # ══════════════════════════════════════════════════════════════ - # LIVRAISON & LOGISTIQUE - # ══════════════════════════════════════════════════════════════ - 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)") - - # JOURS DE COMMANDE (0=non, 1=oui) - CT_OrderDay01-07 - jours_commande: Optional[dict] = Field( - None, - description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche" +class ClientCreateRequest(BaseModel): + + intitule: str = Field( + ..., + max_length=69, + description="Nom du client (CT_Intitule) - OBLIGATOIRE" ) - # JOURS DE LIVRAISON (0=non, 1=oui) - CT_DeliveryDay01-07 - jours_livraison: Optional[dict] = Field( - None, - description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche" + numero: Optional[str] = Field( + None, + max_length=17, + description="Numéro client CT_Num (auto si None)" ) - # DATES FERMETURE - date_fermeture_debut: Optional[date] = Field(None, description="CT_DateFermeDebut") - date_fermeture_fin: Optional[date] = Field(None, description="CT_DateFermeFin") + 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)" + ) - # ══════════════════════════════════════════════════════════════ - # STATISTIQUES PERSONNALISÉES (10 champs de 21 chars max) - # ══════════════════════════════════════════════════════════════ 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") @@ -569,250 +554,289 @@ class ClientCreateAPIRequest(BaseModel): statistique09: Optional[str] = Field(None, max_length=21, description="CT_Statistique09") statistique10: Optional[str] = Field(None, max_length=21, description="CT_Statistique10") - # ══════════════════════════════════════════════════════════════ - # COMMENTAIRE - # ══════════════════════════════════════════════════════════════ - commentaire: Optional[str] = Field(None, max_length=35, description="CT_Commentaire") + encours_autorise: Optional[float] = Field( + None, + description="CT_Encours (montant max autorisé)" + ) - # ══════════════════════════════════════════════════════════════ - # ANALYTIQUE - # ══════════════════════════════════════════════════════════════ - section_analytique: Optional[str] = Field(None, max_length=13, description="CA_Num") - section_analytique_ifrs: Optional[str] = Field(None, max_length=13, description="CA_NumIFRS") - plan_analytique: Optional[int] = Field(None, ge=0, description="N_Analytique") - plan_analytique_ifrs: Optional[int] = Field(None, ge=0, description="N_AnalytiqueIFRS") + assurance_credit: Optional[float] = Field( + None, + description="CT_Assurance (montant assurance crédit)" + ) - # ══════════════════════════════════════════════════════════════ - # ORGANISATION - # ══════════════════════════════════════════════════════════════ - depot_code: Optional[int] = Field(None, description="DE_No (int)") - etablissement_code: Optional[int] = Field(None, description="EB_No (int)") - mode_reglement_code: Optional[int] = Field(None, description="MR_No (int)") - calendrier_code: Optional[int] = Field(None, description="CAL_No (int)") - num_centrale: Optional[str] = Field(None, max_length=17, description="CT_NumCentrale") + langue: Optional[int] = Field( + None, + ge=0, + description="CT_Langue (0=Français, 1=Anglais, etc.)" + ) - # ══════════════════════════════════════════════════════════════ - # SURVEILLANCE COFACE - # ══════════════════════════════════════════════════════════════ - coface: Optional[str] = Field(None, max_length=25, description="CT_Coface") - surveillance_active: Optional[int] = Field(None, ge=0, le=1, description="CT_Surveillance") - sv_date_creation: Optional[datetime] = Field(None, description="CT_SvDateCreate") - sv_chiffre_affaires: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA") - sv_resultat: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvResultat") - sv_incident: Optional[int] = Field(None, ge=0, le=1, description="CT_SvIncident") - sv_date_incident: Optional[datetime] = Field(None, description="CT_SvDateIncid") - sv_privilege: Optional[int] = Field(None, ge=0, le=1, description="CT_SvPrivil") - 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_date_maj: Optional[datetime] = Field(None, description="CT_SvDateMaj") - sv_objet_maj: Optional[str] = Field(None, max_length=61, description="CT_SvObjetMaj") - sv_date_bilan: Optional[datetime] = Field(None, description="CT_SvDateBilan") - sv_nb_mois_bilan: Optional[int] = Field(None, ge=0, description="CT_SvNbMoisBilan") + commercial_code: Optional[int] = Field( + None, + description="CO_No (ID du collaborateur commercial)" + ) - # ══════════════════════════════════════════════════════════════ - # FACTURATION ÉLECTRONIQUE - # ══════════════════════════════════════════════════════════════ - facture_electronique: Optional[int] = Field(None, ge=0, le=1, description="CT_FactureElec") - edi_code_type: Optional[int] = Field(None, description="CT_EdiCodeType") - edi_code: Optional[str] = Field(None, max_length=23, description="CT_EdiCode") - edi_code_sage: Optional[str] = Field(None, max_length=9, description="CT_EdiCodeSage") - fe_assujetti: Optional[int] = Field(None, description="CT_FEAssujetti") - fe_autre_identif_type: Optional[int] = Field(None, description="CT_FEAutreIdentifType") - fe_autre_identif_val: Optional[str] = Field(None, max_length=81, description="CT_FEAutreIdentifVal") - fe_entite_type: Optional[int] = Field(None, description="CT_FEEntiteType") - fe_emission: Optional[int] = Field(None, description="CT_FEEmission") - fe_application: Optional[int] = Field(None, description="CT_FEApplication") - fe_date_synchro: Optional[datetime] = Field(None, description="CT_FEDateSynchro") + lettrage_auto: Optional[bool] = Field( + True, + description="CT_Lettrage (1=oui, 0=non)" + ) - # ══════════════════════════════════════════════════════════════ - # ÉCHANGES & INTÉGRATION - # ══════════════════════════════════════════════════════════════ - echange_rappro: Optional[int] = Field(None, description="CT_EchangeRappro") - echange_cr: Optional[int] = Field(None, description="CT_EchangeCR") - pi_no_echange: Optional[int] = Field(None, description="PI_NoEchange") - annulation_cr: Optional[int] = Field(None, description="CT_AnnulationCR") - profil_societe: Optional[int] = Field(None, description="CT_ProfilSoc") - statut_contrat: Optional[int] = Field(None, description="CT_StatutContrat") + est_actif: Optional[bool] = Field( + True, + description="Inverse de CT_Sommeil (True=actif, False=en sommeil)" + ) - # ══════════════════════════════════════════════════════════════ - # RGPD & CONFIDENTIALITÉ - # ══════════════════════════════════════════════════════════════ - rgpd_consentement: Optional[int] = Field(None, ge=0, le=1, description="CT_GDPR") - exclure_traitement: Optional[int] = Field(None, ge=0, le=1, description="CT_ExclureTrait") + type_facture: Optional[int] = Field( + 1, + ge=0, + le=2, + description="CT_Facture: 0=aucune, 1=normale, 2=regroupée" + ) - # ══════════════════════════════════════════════════════════════ - # REPRÉSENTANT FISCAL - # ══════════════════════════════════════════════════════════════ - representant_intl: Optional[str] = Field(None, max_length=35, description="CT_RepresentInt") - representant_nif: Optional[str] = Field(None, max_length=25, description="CT_RepresentNIF") + est_prospect: Optional[bool] = Field( + False, + description="CT_Prospect (1=oui, 0=non)" + ) - # ══════════════════════════════════════════════════════════════ - # CHAMPS PERSONNALISÉS (Info Libres Sage) - # ══════════════════════════════════════════════════════════════ - date_creation_societe: Optional[datetime] = Field(None, description="Date création société (Info Libre)") - capital_social: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="Capital social") - actionnaire_principal: Optional[str] = Field(None, max_length=69, description="Actionnaire Pal") - score_banque_france: Optional[str] = Field(None, max_length=14, description="Score Banque de France") + bl_en_facture: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_BLFact (impression BL sur facture)" + ) - # FIDÉLITÉ - total_points_fidelite: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6) - points_fidelite_restants: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6) - date_fin_carte_fidelite: Optional[datetime] = Field(None, description="Fin validité carte fidélité") - date_negociation_reglement: Optional[datetime] = Field(None, description="Date négociation règlement") + saut_page: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_Saut (saut de page après impression)" + ) - # ══════════════════════════════════════════════════════════════ - # AUTRES - # ══════════════════════════════════════════════════════════════ - mode_test: Optional[int] = Field(None, ge=0, le=1, description="CT_ModeTest") - confiance: Optional[int] = Field(None, description="CT_Confiance") - dn_id: Optional[str] = Field(None, max_length=37, description="DN_Id") + validation_echeance: Optional[int] = Field( + None, + ge=0, + le=1, + description="CT_ValidEch" + ) - # ══════════════════════════════════════════════════════════════ - # MÉTADONNÉES (en lecture seule généralement) - # ══════════════════════════════════════════════════════════════ - date_creation: Optional[datetime] = Field(None, description="cbCreation - géré par Sage") - date_modification: Optional[datetime] = Field(None, description="cbModification - géré par Sage") - date_maj: Optional[datetime] = Field(None, description="CT_DateMAJ") + 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" + ) - # ══════════════════════════════════════════════════════════════ - # VALIDATORS - # ══════════════════════════════════════════════════════════════ @field_validator('siret') @classmethod def validate_siret(cls, v): - if v and v.lower() not in ('none', ''): + """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('siren') - @classmethod - def validate_siren(cls, v): - if v and v.lower() not in ('none', ''): - cleaned = v.replace(' ', '') - if len(cleaned) != 9: - raise ValueError('Le SIREN doit contenir 9 caractères') - return cleaned - return None - @field_validator('email') @classmethod def validate_email(cls, v): - if v and v.lower() not in ('none', ''): + """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.strip() + return v return None - @field_validator('adresse', 'code_postal', 'ville', 'pays', 'telephone', 'tva_intra', mode='before') + @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' en None""" + """Convertit les chaînes 'None'/'null'/'' en None""" if isinstance(v, str) and v.lower() in ('none', 'null', ''): return None return v - @field_validator('est_en_sommeil', mode='before') - @classmethod - def compute_sommeil(cls, v, info): - """Calcule est_en_sommeil depuis est_actif si non fourni""" - if v is None and 'est_actif' in info.data: - return not info.data.get('est_actif', True) - return v - def to_sage_dict(self) -> dict: - """Convertit le modèle en dictionnaire compatible avec la méthode creer_client""" + """ + 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 { - # Identification "intitule": self.intitule, - "num": self.numero, + "numero": self.numero, "type_tiers": self.type_tiers, "qualite": self.qualite, "classement": self.classement, "raccourci": self.raccourci, - # Statuts - "prospect": self.est_prospect, - "sommeil": not self.est_actif if self.est_en_sommeil is None else self.est_en_sommeil, + "siret": self.siret, + "tva_intra": self.tva_intra, + "code_naf": self.code_naf, - # Adresse "contact": self.contact, "adresse": self.adresse, "complement": self.complement, "code_postal": self.code_postal, "ville": self.ville, - "code_region": self.region, + "region": self.region, "pays": self.pays, - # Communication "telephone": self.telephone, "telecopie": self.telecopie, "email": self.email, - "site": self.site_web, + "site_web": self.site_web, + "portable": self.portable, "facebook": self.facebook, "linkedin": self.linkedin, - # Identifiants légaux - "siret": self.siret, - "tva_intra": self.tva_intra, - "ape": self.code_naf, - "type_nif": self.type_nif, + "compte_general": self.compte_general, - # Banque & devise - "banque_num": self.banque_num, - "devise": self.devise, + "categorie_tarifaire": self.categorie_tarifaire, + "categorie_comptable": self.categorie_comptable, - # Catégories - "cat_tarif": self.categorie_tarifaire or 1, - "cat_compta": self.categorie_comptable or 1, - "period": self.periode_reglement or 1, - "expedition": self.mode_expedition or 1, - "condition": self.condition_livraison or 1, - "risque": self.niveau_risque or 1, - - # Taux "taux01": self.taux01, "taux02": self.taux02, "taux03": self.taux03, "taux04": self.taux04, - # Gestion commerciale - "encours": self.encours_autorise, - "assurance": self.assurance_credit, - "num_payeur": self.num_payeur, - "langue": self.langue, - "langue_iso2": self.langue_iso2, - "compte_collectif": self.compte_general or "411000", - "collaborateur": self.commercial_code, - - # Facturation - "facture": self.type_facture, - "bl_fact": self.bl_en_facture, - "saut": self.saut_page, - "lettrage": self.lettrage_auto, - "valid_ech": self.validation_echeance, - "control_enc": self.controle_encours, - "not_rappel": self.exclure_relance, - "not_penal": self.exclure_penalites, - "bon_a_payer": self.bon_a_payer, - - # Livraison - "priorite_livr": self.priorite_livraison, - "livr_partielle": self.livraison_partielle, - "delai_transport": self.delai_transport, - "delai_appro": self.delai_appro, - "date_ferme_debut": self.date_fermeture_debut, - "date_ferme_fin": self.date_fermeture_fin, - - # Jours commande/livraison - **(self._expand_jours("order_day", self.jours_commande) if self.jours_commande else {}), - **(self._expand_jours("delivery_day", self.jours_livraison) if self.jours_livraison else {}), - - # Statistiques - "statistique01": self.statistique01 or self.secteur, + "statistique01": stat01, "statistique02": self.statistique02, "statistique03": self.statistique03, "statistique04": self.statistique04, @@ -822,130 +846,210 @@ class ClientCreateAPIRequest(BaseModel): "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 "commentaire": self.commentaire, - # Analytique "section_analytique": self.section_analytique, - "section_analytique_ifrs": self.section_analytique_ifrs, - "plan_analytique": self.plan_analytique, - "plan_analytique_ifrs": self.plan_analytique_ifrs, - # Organisation - "depot": self.depot_code, - "etablissement": self.etablissement_code, - "mode_regl": self.mode_reglement_code, - "calendrier": self.calendrier_code, - "num_centrale": self.num_centrale, + "mode_reglement_code": self.mode_reglement_code, - # Surveillance + "surveillance_active": self.surveillance_active, "coface": self.coface, - "surveillance": self.surveillance_active, - "sv_forme_juri": self.forme_juridique, - "sv_effectif": self.effectif, - "sv_ca": self.sv_chiffre_affaires or self.ca_annuel, - "sv_resultat": self.sv_resultat, - "sv_incident": self.sv_incident, - "sv_date_incid": self.sv_date_incident, - "sv_privil": self.sv_privilege, - "sv_regul": self.sv_regularite, + "forme_juridique": self.forme_juridique, + "effectif": self.effectif, + "sv_regularite": self.sv_regularite, "sv_cotation": self.sv_cotation, - "sv_date_create": self.sv_date_creation, - "sv_date_maj": self.sv_date_maj, "sv_objet_maj": self.sv_objet_maj, - "sv_date_bilan": self.sv_date_bilan, - "sv_nb_mois_bilan": self.sv_nb_mois_bilan, - - # Facturation électronique - "facture_elec": self.facture_electronique, - "edi_code_type": self.edi_code_type, - "edi_code": self.edi_code, - "edi_code_sage": self.edi_code_sage, - "fe_assujetti": self.fe_assujetti, - "fe_autre_identif_type": self.fe_autre_identif_type, - "fe_autre_identif_val": self.fe_autre_identif_val, - "fe_entite_type": self.fe_entite_type, - "fe_emission": self.fe_emission, - "fe_application": self.fe_application, - - # Échanges - "echange_rappro": self.echange_rappro, - "echange_cr": self.echange_cr, - "annulation_cr": self.annulation_cr, - "profil_soc": self.profil_societe, - "statut_contrat": self.statut_contrat, - - # RGPD - "gdpr": self.rgpd_consentement, - "exclure_trait": self.exclure_traitement, - - # Représentant - "represent_int": self.representant_intl, - "represent_nif": self.representant_nif, - - # Autres - "mode_test": self.mode_test, - "confiance": self.confiance, + "ca_annuel": ca, + "sv_chiffre_affaires": self.sv_chiffre_affaires, + "sv_resultat": self.sv_resultat, } - def _expand_jours(self, prefix: str, jours: dict) -> dict: - """Expand les jours en champs individuels""" - mapping = { - "lundi": f"{prefix}_lundi", - "mardi": f"{prefix}_mardi", - "mercredi": f"{prefix}_mercredi", - "jeudi": f"{prefix}_jeudi", - "vendredi": f"{prefix}_vendredi", - "samedi": f"{prefix}_samedi", - "dimanche": f"{prefix}_dimanche", - } - return {v: jours.get(k) for k, v in mapping.items() if jours.get(k) is not None} - class Config: json_schema_extra = { "example": { "intitule": "ENTREPRISE EXEMPLE SARL", "numero": "CLI00123", - "compte_general": "411000", + "type_tiers": 0, "qualite": "CLI", + "compte_general": "411000", "est_prospect": False, - "est_actif": True + "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""" - intitule: Optional[str] = Field(None, min_length=1, max_length=69) + +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) - email: Optional[EmailStr] = None + 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) - forme_juridique: Optional[str] = Field(None, max_length=50) - siret: Optional[str] = Field(None, max_length=14) - tva_intra: Optional[str] = Field(None, max_length=25) - + 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" } } -from pydantic import BaseModel -from typing import List, Optional -from datetime import datetime - -# ===================================================== -# MODÈLES PYDANTIC POUR USERS -# ===================================================== class UserResponse(BaseModel): @@ -1881,7 +1985,7 @@ templates_signature_email = { } -async def universign_envoyer_avec_email( +async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, @@ -1899,16 +2003,12 @@ async def universign_envoyer_avec_email( logger.info(f"🔐 Démarrage processus Universign pour {email}") logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})") - # Vérification PDF 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") - # ======================================== - # ÉTAPE 1 : Créer la transaction - # ======================================== - logger.info(f"ÉTAPE 1/6 : Création transaction") + logger.info("ÉTAPE 1/6 : Création transaction") response = requests.post( f"{api_url}/transactions", @@ -1927,10 +2027,7 @@ async def universign_envoyer_avec_email( transaction_id = response.json().get("id") logger.info(f"Transaction créée: {transaction_id}") - # ======================================== - # ÉTAPE 2 : Upload du fichier PDF - # ======================================== - logger.info(f"ÉTAPE 2/6 : Upload PDF") + logger.info("ÉTAPE 2/6 : Upload PDF") files = { "file": ( @@ -1954,10 +2051,7 @@ async def universign_envoyer_avec_email( file_id = response.json().get("id") logger.info(f"Fichier uploadé: {file_id}") - # ======================================== - # ÉTAPE 3 : Ajouter le document - # ======================================== - logger.info(f"📋 ÉTAPE 3/6 : Ajout document à transaction") + logger.info("📋 ÉTAPE 3/6 : Ajout document à transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", @@ -1973,17 +2067,13 @@ async def universign_envoyer_avec_email( document_id = response.json().get("id") logger.info(f"Document ajouté: {document_id}") - # ======================================== - # ÉTAPE 4 : Créer le champ de signature - # ======================================== - logger.info(f"✍️ ÉTAPE 4/6 : Création champ signature") + 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", - # Laisser Universign positionner automatiquement }, timeout=30, ) @@ -1995,10 +2085,7 @@ async def universign_envoyer_avec_email( field_id = response.json().get("id") logger.info(f"Champ créé: {field_id}") - # ======================================== - # ÉTAPE 5 : Lier le signataire au champ (ancien endpoint) - # ======================================== - logger.info(f"👤 ÉTAPE 5/6 : Liaison signataire au champ") + logger.info("👤 ÉTAPE 5/6 : Liaison signataire au champ") response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers @@ -2016,10 +2103,7 @@ async def universign_envoyer_avec_email( logger.info(f"Signataire lié: {email}") - # ======================================== - # ÉTAPE 6 : Démarrer la transaction - # ======================================== - logger.info(f"🚀 ÉTAPE 6/6 : Démarrage transaction") + logger.info("🚀 ÉTAPE 6/6 : Démarrage transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/start", @@ -2032,23 +2116,18 @@ async def universign_envoyer_avec_email( raise Exception(f"Erreur démarrage: {response.status_code}") final_data = response.json() - logger.info(f"Transaction démarrée") + logger.info("Transaction démarrée") - # ======================================== - # Récupérer l'URL de signature - # ======================================== - logger.info(f"🔗 Récupération URL de signature") + logger.info("🔗 Récupération URL de signature") signer_url = "" - # Chercher dans actions if final_data.get("actions"): for action in final_data["actions"]: if action.get("url"): signer_url = action["url"] break - # Sinon chercher dans signers if not signer_url and final_data.get("signers"): for signer in final_data["signers"]: if signer.get("email") == email: @@ -2059,12 +2138,9 @@ async def universign_envoyer_avec_email( logger.error(f"URL introuvable dans: {final_data}") raise ValueError("URL de signature non retournée par Universign") - logger.info(f"URL récupérée") + logger.info("URL récupérée") - # ======================================== - # Créer l'email de notification - # ======================================== - logger.info(f"📧 Préparation email") + logger.info("📧 Préparation email") template = templates_signature_email["demande_signature"] @@ -2111,7 +2187,7 @@ async def universign_envoyer_avec_email( email_queue.enqueue(email_log.id) logger.info(f"Email mis en file pour {email}") - logger.info(f"🎉 Processus terminé avec succès") + logger.info("🎉 Processus terminé avec succès") return { "transaction_id": transaction_id, @@ -2165,7 +2241,6 @@ async def universign_statut(transaction_id: str) -> Dict: @asynccontextmanager async def lifespan(app: FastAPI): - # Init base de données await init_db() logger.info("Base de données initialisée") @@ -2174,20 +2249,16 @@ async def lifespan(app: FastAPI): logger.info("sage_client injecté dans email_queue") - # Démarrer queue email_queue.start(num_workers=settings.max_email_workers) - logger.info(f"Email queue démarrée") + logger.info("Email queue démarrée") yield - # Cleanup email_queue.stop() logger.info("👋 Services arrêtés") -# ===================================================== -# APPLICATION -# ===================================================== + app = FastAPI( title="API Sage 100c Dataven", version="2.0.0", @@ -2242,7 +2313,6 @@ async def modifier_client( session: AsyncSession = Depends(get_session), ): try: - # Appel à la gateway Windows resultat = sage_client.modifier_client( code, client_update.dict(exclude_none=True) ) @@ -2256,18 +2326,16 @@ async def modifier_client( } except ValueError as e: - # Erreur métier (client introuvable, etc.) logger.warning(f"Erreur métier modification client {code}: {e}") raise HTTPException(404, str(e)) except Exception as e: - # Erreur technique 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: ClientCreateAPIRequest, session: AsyncSession = Depends(get_session) + client: ClientCreateRequest, session: AsyncSession = Depends(get_session) ): try: nouveau_client = sage_client.creer_client(client.model_dump(mode='json')) @@ -2282,7 +2350,6 @@ async def ajouter_client( except Exception as e: logger.error(f"Erreur lors de la création du client: {e}") - # On renvoie une 400 si c'est une erreur métier (ex: doublon), sinon 500 status = 400 if "existe déjà" in str(e) else 500 raise HTTPException(status, str(e)) @@ -2305,7 +2372,6 @@ async def rechercher_articles(query: Optional[str] = Query(None)): ) async def creer_article(article: ArticleCreateRequest): try: - # Validation des données if not article.reference or not article.designation: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -2316,7 +2382,6 @@ async def creer_article(article: ArticleCreateRequest): logger.info(f"Création article: {article.reference} - {article.designation}") - # Appel à la gateway Windows resultat = sage_client.creer_article(article_data) logger.info( @@ -2326,7 +2391,6 @@ async def creer_article(article: ArticleCreateRequest): return ArticleResponse(**resultat) except ValueError as e: - # Erreur métier (ex: article existe déjà) logger.warning(f"Erreur métier création article: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @@ -2334,7 +2398,6 @@ async def creer_article(article: ArticleCreateRequest): raise except Exception as e: - # Erreur technique Sage logger.error(f"Erreur technique création article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -2358,10 +2421,8 @@ async def modifier_article( logger.info(f"Modification article {reference}: {list(article_data.keys())}") - # Appel à la gateway Windows resultat = sage_client.modifier_article(reference, article_data) - # Log spécial pour modification de stock (important pour erreur 2881) if "stock_reel" in article_data: logger.info( f"Stock {reference} modifié: {article_data['stock_reel']} " @@ -2373,7 +2434,6 @@ async def modifier_article( return ArticleResponse(**resultat) except ValueError as e: - # Erreur métier (ex: article introuvable) logger.warning(f"Erreur métier modification article: {e}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) @@ -2381,7 +2441,6 @@ async def modifier_article( raise except Exception as e: - # Erreur technique Sage logger.error(f"Erreur technique modification article: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -2432,7 +2491,6 @@ def lister_articles(filtre: str = ""): @app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"]) async def creer_devis(devis: DevisRequest): try: - # Préparer les données pour la gateway devis_data = { "client_id": devis.client_id, "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, @@ -2442,15 +2500,14 @@ async def creer_devis(devis: DevisRequest): "reference": devis.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in devis.lignes + for ligne in devis.lignes ], } - # Appel HTTP vers Windows resultat = sage_client.creer_devis(devis_data) logger.info(f"Devis créé: {resultat.get('numero_devis')}") @@ -2484,11 +2541,11 @@ async def modifier_devis( if devis_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in devis_update.lignes + for ligne in devis_update.lignes ] if devis_update.statut is not None: @@ -2497,7 +2554,6 @@ async def modifier_devis( if devis_update.reference is not None: update_data["reference"] = devis_update.reference - # Appel à la gateway Windows resultat = sage_client.modifier_devis(id, update_data) logger.info(f"Devis {id} modifié avec succès") @@ -2531,15 +2587,14 @@ async def creer_commande( "reference": commande.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in commande.lignes + for ligne in commande.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_commande(commande_data) logger.info(f"Commande créée: {resultat.get('numero_commande')}") @@ -2580,11 +2635,11 @@ async def modifier_commande( if commande_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in commande_update.lignes + for ligne in commande_update.lignes ] if commande_update.statut is not None: @@ -2593,7 +2648,6 @@ async def modifier_commande( if commande_update.reference is not None: update_data["reference"] = commande_update.reference - # Appel à la gateway Windows resultat = sage_client.modifier_commande(id, update_data) logger.info(f"Commande {id} modifiée avec succès") @@ -2650,8 +2704,6 @@ async def lire_devis(id: str): @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): try: - # Générer PDF en appelant la méthode de email_queue - # qui elle-même appellera sage_client pour récupérer les données pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) return StreamingResponse( @@ -2673,7 +2725,6 @@ async def telecharger_document_pdf( numero: str = Path(..., description="Numéro du document"), ): try: - # Mapping des types vers les libellés types_labels = { 0: "Devis", 10: "Commande", @@ -2684,7 +2735,6 @@ async def telecharger_document_pdf( 60: "Facture", } - # Vérifier que le type est valide if type_doc not in types_labels: raise HTTPException( 400, @@ -2696,7 +2746,6 @@ async def telecharger_document_pdf( logger.info(f"Génération PDF: {label} {numero} (type={type_doc})") - # Appel à sage_client pour générer le PDF pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) if not pdf_bytes: @@ -2704,7 +2753,6 @@ async def telecharger_document_pdf( logger.info(f"PDF généré: {len(pdf_bytes)} octets") - # Nom de fichier formaté filename = f"{label}_{numero}.pdf" return StreamingResponse( @@ -2780,14 +2828,12 @@ async def changer_statut_devis( ), ): try: - # Vérifier que le devis existe 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) - # Vérifications de cohérence if statut_actuel == 5: raise HTTPException( 400, @@ -2848,14 +2894,12 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): try: - # Étape 1: Transformation resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) - # Étape 3: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -2942,19 +2986,16 @@ async def envoyer_signature_optimise( demande: SignatureRequest, session: AsyncSession = Depends(get_session) ): try: - # Récupérer le document depuis Sage 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") - # Générer PDF pdf_bytes = email_queue._generate_pdf( demande.doc_id, normaliser_type_doc(demande.type_doc) ) - # Préparer les données du document pour l'email doc_data = { "type_doc": demande.type_doc, "type_label": { @@ -2968,8 +3009,7 @@ async def envoyer_signature_optimise( "date": doc.get("date", datetime.now().strftime("%d/%m/%Y")), } - # Envoi Universign + Email automatique - resultat = await universign_envoyer_avec_email( + resultat = await universign_envoyer( doc_id=demande.doc_id, pdf_bytes=pdf_bytes, email=demande.email_signataire, @@ -2981,7 +3021,6 @@ async def envoyer_signature_optimise( if "error" in resultat: raise HTTPException(500, resultat["error"]) - # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=demande.doc_id, @@ -2997,7 +3036,6 @@ async def envoyer_signature_optimise( session.add(signature_log) await session.commit() - # MAJ champ libre Sage sage_client.mettre_a_jour_champ_libre( demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"] ) @@ -3036,7 +3074,6 @@ async def webhook_universign( logger.warning("Webhook sans transaction_id") return {"status": "ignored"} - # Chercher la signature dans la DB query = select(SignatureLog).where( SignatureLog.transaction_id == transaction_id ) @@ -3047,18 +3084,13 @@ async def webhook_universign( logger.warning(f"Transaction {transaction_id} introuvable en DB") return {"status": "not_found"} - # ============================================= - # TRAITER L'EVENT SELON LE TYPE - # ============================================= if event_type == "transaction.completed": - # SIGNATURE RÉUSSIE signature_log.statut = StatutSignatureEnum.SIGNE signature_log.date_signature = datetime.now() logger.info(f"Signature confirmée: {signature_log.document_id}") - # ENVOYER EMAIL DE CONFIRMATION template = templates_signature_email["signature_confirmee"] type_labels = { @@ -3085,7 +3117,6 @@ async def webhook_universign( sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - # Créer email de confirmation email_log = EmailLog( id=str(uuid.uuid4()), destinataire=signature_log.email_signataire, @@ -3106,12 +3137,10 @@ async def webhook_universign( ) elif event_type == "transaction.refused": - # SIGNATURE REFUSÉE signature_log.statut = StatutSignatureEnum.REFUSE logger.warning(f"Signature refusée: {signature_log.document_id}") elif event_type == "transaction.expired": - # ⏰ TRANSACTION EXPIRÉE signature_log.statut = StatutSignatureEnum.EXPIRE logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}") @@ -3133,7 +3162,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se try: from datetime import timedelta - # Chercher signatures en attente depuis > 7 jours date_limite = datetime.now() - timedelta(days=7) query = select(SignatureLog).where( @@ -3151,16 +3179,13 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se for signature in signatures_a_relancer: try: - # Calculer jours écoulés nb_jours = (datetime.now() - signature.date_envoi).days jours_restants = 30 - nb_jours # Lien expire après 30 jours if jours_restants <= 0: - # Transaction expirée signature.statut = StatutSignatureEnum.EXPIRE continue - # Préparer email de relance template = templates_signature_email["relance_signature"] type_labels = { @@ -3188,7 +3213,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - # Créer email de relance email_log = EmailLog( id=str(uuid.uuid4()), destinataire=signature.email_signataire, @@ -3204,7 +3228,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se session.add(email_log) email_queue.enqueue(email_log.id) - # Incrémenter compteur de relances signature.est_relance = True signature.nb_relances = (signature.nb_relances or 0) + 1 @@ -3234,7 +3257,6 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se @app.get("/signature/universign/status", tags=["Signatures"]) async def statut_signature(docId: str = Query(...)): - # Chercher dans la DB locale try: async with async_session_factory() as session: query = select(SignatureLog).where(SignatureLog.document_id == docId) @@ -3244,7 +3266,6 @@ async def statut_signature(docId: str = Query(...)): if not signature_log: raise HTTPException(404, "Signature introuvable") - # Interroger Universign statut = await universign_statut(signature_log.transaction_id) return { @@ -3307,7 +3328,6 @@ async def statut_signature_detail( if not signature_log: raise HTTPException(404, f"Transaction {transaction_id} introuvable") - # Interroger Universign statut_universign = await universign_statut(transaction_id) if statut_universign.get("statut") != "ERREUR": @@ -3402,15 +3422,12 @@ async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): try: - # Vérifier devis via gateway Windows devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - # Générer PDF pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) - # Envoi Universign resultat = await universign_envoyer( id, pdf_bytes, request.email_signataire, request.nom_signataire ) @@ -3418,7 +3435,6 @@ async def envoyer_devis_signature( if "error" in resultat: raise HTTPException(500, f"Erreur Universign: {resultat['error']}") - # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, @@ -3434,7 +3450,6 @@ async def envoyer_devis_signature( session.add(signature_log) await session.commit() - # MAJ champ libre Sage via gateway sage_client.mettre_a_jour_champ_libre( id, TypeDocument.DEVIS, "UniversignID", resultat["transaction_id"] ) @@ -3546,20 +3561,16 @@ async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): try: - # Lire devis via gateway devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - # Récupérer contact via gateway 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") - # Générer PDF pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) - # Envoi Universign resultat = await universign_envoyer( id, pdf_bytes, @@ -3570,7 +3581,6 @@ async def relancer_devis_signature( if "error" in resultat: raise HTTPException(500, resultat["error"]) - # Logger en DB signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, @@ -3614,12 +3624,10 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): try: - # Lire devis via gateway Windows devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - # Lire contact via gateway Windows contact = sage_client.lire_contact_client(devis["client_code"]) if not contact: raise HTTPException( @@ -3636,9 +3644,7 @@ async def recuperer_contact_devis(id: str): raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A7 -# ===================================================== + @app.get("/factures", tags=["Factures"]) @@ -3692,15 +3698,14 @@ async def creer_facture( "reference": facture.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in facture.lignes + for ligne in facture.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_facture(facture_data) logger.info(f"Facture créée: {resultat.get('numero_facture')}") @@ -3741,11 +3746,11 @@ async def modifier_facture( if facture_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in facture_update.lignes + for ligne in facture_update.lignes ] if facture_update.statut is not None: @@ -3754,7 +3759,6 @@ async def modifier_facture( if facture_update.reference is not None: update_data["reference"] = facture_update.reference - # Appel à la gateway Windows resultat = sage_client.modifier_facture(id, update_data) logger.info(f"Facture {id} modifiée avec succès") @@ -3802,17 +3806,14 @@ async def relancer_facture( session: AsyncSession = Depends(get_session), ): try: - # Lire facture via gateway Windows facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {id} introuvable") - # Récupérer contact via gateway Windows 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") - # Préparer email template = templates_email_db["relance_facture"] variables = { @@ -3830,7 +3831,6 @@ async def relancer_facture( sujet = sujet.replace(f"{{{{{var}}}}}", valeur) corps = corps.replace(f"{{{{{var}}}}}", valeur) - # Créer log email email_log = EmailLog( id=str(uuid.uuid4()), destinataire=contact["email"], @@ -3846,7 +3846,6 @@ async def relancer_facture( session.add(email_log) await session.flush() - # Enqueue email_queue.enqueue(email_log.id) sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) @@ -3918,11 +3917,9 @@ async def exporter_logs_csv( result = await session.execute(query) logs = result.scalars().all() - # Génération CSV output = io.StringIO() writer = csv.writer(output) - # En-têtes writer.writerow( [ "ID", @@ -3937,7 +3934,6 @@ async def exporter_logs_csv( ] ) - # Données for log in logs: writer.writerow( [ @@ -4017,7 +4013,6 @@ 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") - # Ne pas modifier les templates système if template_id in ["relance_devis", "relance_facture"]: raise HTTPException(400, "Les templates système ne peuvent pas être modifiés") @@ -4056,12 +4051,10 @@ async def previsualiser_email(preview: TemplatePreviewRequest): template = templates_email_db[preview.template_id] - # Lire document via gateway Windows doc = sage_client.lire_document(preview.document_id, preview.type_document) if not doc: raise HTTPException(404, f"Document {preview.document_id} introuvable") - # Variables variables = { "DO_Piece": doc.get("numero", preview.document_id), "DO_Date": str(doc.get("date", "")), @@ -4070,7 +4063,6 @@ async def previsualiser_email(preview: TemplatePreviewRequest): "DO_TotalTTC": f"{doc.get('total_ttc', 0):.2f}", } - # Fusion sujet_preview = template["sujet"] corps_preview = template["corps_html"] @@ -4087,9 +4079,7 @@ async def previsualiser_email(preview: TemplatePreviewRequest): } -# ===================================================== -# ENDPOINTS - HEALTH -# ===================================================== + @app.get("/health", tags=["System"]) async def health_check(): gateway_health = sage_client.health() @@ -4116,9 +4106,7 @@ async def root(): } -# ===================================================== -# ENDPOINTS - ADMIN -# ===================================================== + @app.get("/admin/cache/info", tags=["Admin"]) @@ -4141,9 +4129,7 @@ async def statut_queue(): } -# ===================================================== -# ENDPOINTS - PROSPECTS -# ===================================================== + @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects(query: Optional[str] = Query(None)): try: @@ -4168,9 +4154,7 @@ async def lire_prospect(code: str): raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - FOURNISSEURS -# ===================================================== + @app.get("/fournisseurs", tags=["Fournisseurs"]) async def rechercher_fournisseurs(query: Optional[str] = Query(None)): try: @@ -4194,7 +4178,6 @@ async def ajouter_fournisseur( session: AsyncSession = Depends(get_session), ): try: - # Appel à la gateway Windows via sage_client nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) logger.info(f"Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") @@ -4206,12 +4189,10 @@ async def ajouter_fournisseur( } except ValueError as e: - # Erreur métier (doublon, validation) logger.warning(f"Erreur métier création fournisseur: {e}") raise HTTPException(400, str(e)) except Exception as e: - # Erreur technique (COM, connexion) logger.error(f"Erreur technique création fournisseur: {e}") raise HTTPException(500, str(e)) @@ -4223,7 +4204,6 @@ async def modifier_fournisseur( session: AsyncSession = Depends(get_session), ): try: - # Appel à la gateway Windows resultat = sage_client.modifier_fournisseur( code, fournisseur_update.dict(exclude_none=True) ) @@ -4237,11 +4217,9 @@ async def modifier_fournisseur( } except ValueError as e: - # Erreur métier (fournisseur introuvable, etc.) logger.warning(f"Erreur métier modification fournisseur {code}: {e}") raise HTTPException(404, str(e)) except Exception as e: - # Erreur technique logger.error(f"Erreur technique modification fournisseur {code}: {e}") raise HTTPException(500, str(e)) @@ -4260,9 +4238,7 @@ async def lire_fournisseur(code: str): raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - AVOIRS -# ===================================================== + @app.get("/avoirs", tags=["Avoirs"]) async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) @@ -4303,15 +4279,14 @@ async def creer_avoir( "reference": avoir.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in avoir.lignes + for ligne in avoir.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_avoir(avoir_data) logger.info(f"Avoir créé: {resultat.get('numero_avoir')}") @@ -4352,11 +4327,11 @@ async def modifier_avoir( if avoir_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in avoir_update.lignes + for ligne in avoir_update.lignes ] if avoir_update.statut is not None: @@ -4365,7 +4340,6 @@ async def modifier_avoir( if avoir_update.reference is not None: update_data["reference"] = avoir_update.reference - # Appel à la gateway Windows resultat = sage_client.modifier_avoir(id, update_data) logger.info(f"Avoir {id} modifié avec succès") @@ -4383,9 +4357,7 @@ async def modifier_avoir( raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - LIVRAISONS -# ===================================================== + @app.get("/livraisons", tags=["Livraisons"]) async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) @@ -4432,15 +4404,14 @@ async def creer_livraison( "reference": livraison.reference, "lignes": [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in livraison.lignes + for ligne in livraison.lignes ], } - # Appel à la gateway Windows resultat = sage_client.creer_livraison(livraison_data) logger.info(f"Livraison créée: {resultat.get('numero_livraison')}") @@ -4481,11 +4452,11 @@ async def modifier_livraison( if livraison_update.lignes is not None: update_data["lignes"] = [ { - "article_code": l.article_code, - "quantite": l.quantite, - "remise_pourcentage": l.remise_pourcentage, + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, } - for l in livraison_update.lignes + for ligne in livraison_update.lignes ] if livraison_update.statut is not None: @@ -4494,7 +4465,6 @@ async def modifier_livraison( if livraison_update.reference is not None: update_data["reference"] = livraison_update.reference - # Appel à la gateway Windows resultat = sage_client.modifier_livraison(id, update_data) logger.info(f"Livraison {id} modifiée avec succès") @@ -4556,7 +4526,6 @@ async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session) ): try: - # Étape 1: Vérifier que le devis n'a pas déjà été transformé devis_existant = sage_client.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") @@ -4569,14 +4538,12 @@ async def devis_vers_facture_direct( f"Vérifiez les documents déjà créés depuis ce devis.", ) - # Étape 2: Transformation resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) - # Étape 4: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -4617,7 +4584,6 @@ async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session) ): try: - # Étape 1: Vérifier que la commande existe commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande_existante: @@ -4637,14 +4603,12 @@ async def commande_vers_livraison( f"La commande {id} est annulée (statut=6) et ne peut pas être transformée.", ) - # Étape 2: Transformation resultat = sage_client.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 ) - # Étape 3: Logger la transformation workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, @@ -4746,7 +4710,6 @@ async def lire_famille( ) async def creer_famille(famille: FamilleCreateRequest): try: - # Validation des données if not famille.code or not famille.intitule: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -4757,7 +4720,6 @@ async def creer_famille(famille: FamilleCreateRequest): logger.info(f"Création famille: {famille.code} - {famille.intitule}") - # Appel à la gateway Windows resultat = sage_client.creer_famille(famille_data) logger.info(f"Famille créée: {resultat.get('code')}") @@ -4765,7 +4727,6 @@ async def creer_famille(famille: FamilleCreateRequest): return FamilleResponse(**resultat) except ValueError as e: - # Erreur métier (ex: famille existe déjà) logger.warning(f"Erreur métier création famille: {e}") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) @@ -4773,7 +4734,6 @@ async def creer_famille(famille: FamilleCreateRequest): raise except Exception as e: - # Erreur technique Sage logger.error(f"Erreur technique création famille: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -4790,14 +4750,12 @@ async def creer_famille(famille: FamilleCreateRequest): ) async def creer_entree_stock(entree: EntreeStockRequest): try: - # Préparer les données 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)") - # Appel à la gateway Windows resultat = sage_client.creer_entree_stock(entree_data) logger.info(f"Entrée stock créée: {resultat.get('numero')}") @@ -4825,14 +4783,12 @@ async def creer_entree_stock(entree: EntreeStockRequest): ) async def creer_sortie_stock(sortie: SortieStockRequest): try: - # Préparer les données 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)") - # Appel à la gateway Windows resultat = sage_client.creer_sortie_stock(sortie_data) logger.info(f"Sortie stock créée: {resultat.get('numero')}") @@ -4914,24 +4870,19 @@ async def lister_utilisateurs_debug( from sqlalchemy import select try: - # Construction de la requête query = select(User) - # Filtres optionnels if role: query = query.where(User.role == role) if verified_only: - query = query.where(User.is_verified == True) + query = query.where(User.is_verified) - # Tri par date de création (plus récents en premier) query = query.order_by(User.created_at.desc()).limit(limit) - # Exécution result = await session.execute(query) users = result.scalars().all() - # Conversion en réponse users_response = [] for user in users: users_response.append( @@ -4966,22 +4917,18 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) from sqlalchemy import select, func try: - # Total utilisateurs total_query = select(func.count(User.id)) total_result = await session.execute(total_query) total = total_result.scalar() - # Utilisateurs vérifiés - verified_query = select(func.count(User.id)).where(User.is_verified == True) + verified_query = select(func.count(User.id)).where(User.is_verified) verified_result = await session.execute(verified_query) verified = verified_result.scalar() - # Utilisateurs actifs - active_query = select(func.count(User.id)).where(User.is_active == True) + active_query = select(func.count(User.id)).where(User.is_active) active_result = await session.execute(active_query) active = active_result.scalar() - # Par rôle 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()} @@ -5021,7 +4968,6 @@ async def get_document_pdf( download: bool = Query(False, description="Télécharger au lieu d'afficher"), ): try: - # Récupérer le PDF (en bytes) pdf_bytes = sage_client.generer_pdf_document( numero=numero, type_doc=type_doc, @@ -5029,7 +4975,6 @@ async def get_document_pdf( base64_encode=False, # On veut les bytes bruts ) - # Retourner le PDF from fastapi.responses import Response disposition = "attachment" if download else "inline" @@ -5046,9 +4991,7 @@ async def get_document_pdf( raise HTTPException(500, str(e)) -# ===================================================== -# LANCEMENT -# ===================================================== + if __name__ == "__main__": uvicorn.run( "api:app",