diff --git a/.env.example b/.env.example index 0995196..314aa07 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ SAGE_GATEWAY_URL=http://192.168.1.50:8100 SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f # === Base de données === -DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db +DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db # === SMTP === SMTP_HOST=smtp.office365.com diff --git a/.gitignore b/.gitignore index 023d3fc..e1a8191 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,4 @@ htmlcov/ # Docker *~ .build/ -dist/ - -*.db +dist/ \ No newline at end of file diff --git a/api.py b/api.py index cf91dc8..fb71362 100644 --- a/api.py +++ b/api.py @@ -1,7 +1,7 @@ -from fastapi import FastAPI, HTTPException, Query, Depends, status +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse -from pydantic import BaseModel, Field, EmailStr +from pydantic import BaseModel, Field, EmailStr, validator, field_validator from typing import List, Optional, Dict from datetime import date, datetime from enum import Enum @@ -42,6 +42,41 @@ from email_queue import email_queue from sage_client import sage_client +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"}, +] + + # ===================================================== # ENUMS # ===================================================== @@ -55,6 +90,16 @@ class TypeDocument(int, Enum): 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" @@ -76,20 +121,226 @@ class StatutEmail(str, Enum): # MODÈLES PYDANTIC # ===================================================== class ClientResponse(BaseModel): - numero: str - intitule: str + """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 + telephone: Optional[str] = None # Téléphone principal (fixe ou mobile) + + +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'", + ) + qualite: Optional[str] = Field( + None, + description="Qualité Sage : CLI (Client), FOU (Fournisseur), PRO (Prospect)", + ) + est_prospect: Optional[bool] = Field( + None, description="True si prospect (CT_Prospect=1)" + ) + est_fournisseur: Optional[bool] = Field( + None, description="True si fournisseur (CT_Qualite=2 ou 3)" + ) + + # === TYPE DE PERSONNE (ENTREPRISE VS PARTICULIER) === + forme_juridique: Optional[str] = Field( + None, + description="Forme juridique (SA, SARL, SAS, EI, etc.) - Vide si particulier", + ) + est_entreprise: Optional[bool] = Field( + None, description="True si entreprise (forme_juridique renseignée)" + ) + est_particulier: Optional[bool] = Field( + 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)") + nom_complet: Optional[str] = Field( + 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") + ville: Optional[str] = Field(None, description="Ville") + region: Optional[str] = Field(None, description="Région/État") + pays: Optional[str] = Field(None, description="Pays") + + # === TÉLÉCOMMUNICATIONS === + telephone: Optional[str] = Field(None, description="Téléphone fixe") + portable: Optional[str] = Field(None, description="Téléphone mobile") + telecopie: Optional[str] = Field(None, description="Fax") + email: Optional[str] = Field(None, description="Email principal") + site_web: Optional[str] = Field(None, description="Site web") + + # === INFORMATIONS JURIDIQUES (ENTREPRISES) === + siret: Optional[str] = Field(None, description="N° SIRET (14 chiffres)") + siren: Optional[str] = Field(None, description="N° SIREN (9 chiffres)") + tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire") + code_naf: Optional[str] = Field(None, description="Code NAF/APE") + + # === INFORMATIONS COMMERCIALES === + secteur: Optional[str] = Field(None, description="Secteur d'activité") + effectif: Optional[int] = Field(None, description="Nombre d'employés") + ca_annuel: Optional[float] = Field(None, description="Chiffre d'affaires annuel") + commercial_code: Optional[str] = Field( + None, description="Code du commercial rattaché" + ) + commercial_nom: Optional[str] = Field(None, description="Nom du commercial") + + # === CATÉGORIES === + categorie_tarifaire: Optional[int] = Field( + None, description="Catégorie tarifaire (N_CatTarif)" + ) + categorie_comptable: Optional[int] = Field( + None, description="Catégorie comptable (N_CatCompta)" + ) + + # === INFORMATIONS FINANCIÈRES === + encours_autorise: Optional[float] = Field( + None, description="Encours maximum autorisé" + ) + assurance_credit: Optional[float] = Field( + None, description="Montant assurance crédit" + ) + compte_general: Optional[str] = Field(None, description="Compte général principal") + + # === DATES === + date_creation: Optional[str] = Field(None, description="Date de création") + date_modification: Optional[str] = Field( + None, description="Date de dernière modification" + ) + + class Config: + json_schema_extra = { + "example": { + "numero": "CLI000001", + "intitule": "SARL EXEMPLE", + "type_tiers": "client", + "qualite": "CLI", + "est_entreprise": True, + "forme_juridique": "SARL", + "adresse": "123 Rue de la Paix", + "code_postal": "75001", + "ville": "Paris", + "telephone": "0123456789", + "portable": "0612345678", + "email": "contact@exemple.fr", + "siret": "12345678901234", + "tva_intra": "FR12345678901", + } + } class ArticleResponse(BaseModel): - reference: str - designation: str - prix_vente: float - stock_reel: float + """ + Modèle de réponse pour un article Sage + + ✅ ENRICHI avec tous les champs disponibles + """ + + # === IDENTIFICATION === + reference: str = Field(..., description="Référence article (AR_Ref)") + designation: str = Field(..., description="Désignation principale (AR_Design)") + designation_complementaire: Optional[str] = Field( + None, description="Désignation complémentaire" + ) + + # === CODE EAN / CODE-BARRES === + code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") + code_barre: Optional[str] = Field(None, description="Code-barres (alias)") + + # === PRIX === + prix_vente: float = Field(..., description="Prix de vente HT") + prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") + prix_revient: Optional[float] = Field(None, description="Prix de revient") + + # === STOCK === + stock_reel: float = Field(..., description="Stock réel") + stock_mini: Optional[float] = Field(None, description="Stock minimum") + stock_maxi: Optional[float] = Field(None, description="Stock maximum") + stock_reserve: Optional[float] = Field( + None, description="Stock réservé (en commande)" + ) + stock_commande: Optional[float] = Field( + None, description="Stock en commande fournisseur" + ) + stock_disponible: Optional[float] = Field( + None, description="Stock disponible (réel - réservé)" + ) + + # === 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)" + ) + type_article_libelle: Optional[str] = Field(None, description="Libellé du type") + famille_code: Optional[str] = Field(None, description="Code famille") + famille_libelle: Optional[str] = Field(None, description="Libellé famille") + + # === FOURNISSEUR PRINCIPAL === + fournisseur_principal: Optional[str] = Field( + None, description="Code fournisseur principal" + ) + fournisseur_nom: Optional[str] = Field( + None, description="Nom fournisseur principal" + ) + + # === 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" + ) class LigneDevis(BaseModel): @@ -98,10 +349,15 @@ class LigneDevis(BaseModel): prix_unitaire_ht: Optional[float] = None 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 + reference: Optional[str] = None lignes: List[LigneDevis] @@ -144,13 +400,1012 @@ class BaremeRemiseResponse(BaseModel): message: str +class ClientCreateAPIRequest(BaseModel): + """Modèle pour création d'un nouveau client""" + + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale ou Nom complet" + ) + compte_collectif: str = Field( + "411000", description="Compte comptable (411000 par défaut)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code client souhaité (auto si vide)" + ) + + # Adresse + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + + # Contact + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21, description="Téléphone fixe") + portable: Optional[str] = Field(None, max_length=21, description="Téléphone mobile") + + # Juridique + forme_juridique: Optional[str] = Field( + None, max_length=50, description="SARL, SA, SAS, EI, etc." + ) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "SARL NOUVELLE ENTREPRISE", + "forme_juridique": "SARL", + "adresse": "10 Avenue des Champs", + "code_postal": "75008", + "ville": "Paris", + "telephone": "0123456789", + "portable": "0612345678", + "email": "contact@nouvelle-entreprise.fr", + "siret": "12345678901234", + "tva_intra": "FR12345678901", + } + } + + +class ClientUpdateRequest(BaseModel): + """Modèle pour modification d'un client existant""" + + intitule: Optional[str] = Field(None, min_length=1, max_length=69) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + 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) + 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) + + class Config: + json_schema_extra = { + "example": { + "email": "nouveau@email.fr", + "telephone": "0198765432", + "portable": "0687654321", + } + } + + +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + # ===================================================== -# SERVICES EXTERNES (Universign) +# MODÈLES PYDANTIC POUR USERS # ===================================================== -async def universign_envoyer( - doc_id: str, pdf_bytes: bytes, email: str, nom: str + + +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 + 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", + "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 + prix_unitaire_ht: Optional[float] = None + 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 + lignes: List[LigneCommande] + reference: Optional[str] = None # Référence externe + + 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 + lignes: Optional[List[LigneCommande]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "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 + prix_unitaire_ht: Optional[float] = None + 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 + 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 + lignes: Optional[List[LigneLivraison]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "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 + prix_unitaire_ht: Optional[float] = None + 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 + 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 + lignes: Optional[List[LigneAvoir]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "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 + prix_unitaire_ht: Optional[float] = None + 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 + 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 + lignes: Optional[List[LigneFacture]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "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 de réponse pour une famille d'articles""" + + code: str = Field(..., description="Code famille") + intitule: str = Field(..., description="Intitulé") + type: int = Field(..., description="Type (0=Détail, 1=Total)") + type_libelle: str = Field(..., description="Libellé du type") + est_total: Optional[bool] = Field(None, description="True si type Total") + compte_achat: Optional[str] = Field(None, description="Compte général achat") + compte_vente: Optional[str] = Field(None, description="Compte général vente") + unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") + coef: Optional[float] = Field(None, description="Coefficient") + + class Config: + json_schema_extra = { + "example": { + "code": "ZDIVERS", + "intitule": "Frais et accessoires", + "type": 0, + "type_libelle": "Détail", + "est_total": False, + "compte_achat": "607000", + "compte_vente": "707000", + "unite_vente": "U", + "coef": 2.0, + } + } + + +class MouvementStockLigneRequest(BaseModel): + 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") + + +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_avec_email( + doc_id: str, + pdf_bytes: bytes, + email: str, + nom: str, + doc_data: Dict, # Données du document (type, montant, date, etc.) + session: AsyncSession, ) -> Dict: - """Envoi signature via API Universign""" import requests try: @@ -158,23 +1413,31 @@ async def universign_envoyer( api_url = settings.universign_api_url auth = (api_key, "") - # Étape 1: Créer transaction response = requests.post( f"{api_url}/transactions", auth=auth, - json={"name": f"Devis {doc_id}", "language": "fr"}, + json={ + "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", + "language": "fr", + }, timeout=30, ) response.raise_for_status() transaction_id = response.json().get("id") - # Étape 2: Upload PDF - files = {"file": (f"Devis_{doc_id}.pdf", pdf_bytes, "application/pdf")} + logger.info(f"✅ Transaction Universign créée: {transaction_id}") + + 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=30) response.raise_for_status() file_id = response.json().get("id") - # Étape 3: Ajouter document response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, @@ -184,7 +1447,6 @@ async def universign_envoyer( response.raise_for_status() document_id = response.json().get("id") - # Étape 4: Créer champ signature response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, @@ -194,7 +1456,6 @@ async def universign_envoyer( response.raise_for_status() field_id = response.json().get("id") - # Étape 5: Assigner signataire response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", auth=auth, @@ -203,7 +1464,6 @@ async def universign_envoyer( ) response.raise_for_status() - # Étape 6: Démarrer transaction response = requests.post( f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 ) @@ -216,17 +1476,72 @@ async def universign_envoyer( else "" ) - logger.info(f"✅ Signature Universign envoyée: {transaction_id}") + if not signer_url: + raise ValueError("URL de signature non retournée par Universign") + + logger.info(f"✅ Signature Universign démarrée: {transaction_id}") + + template = templates_signature_email["demande_signature"] + + # Préparer les variables + 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, + } + + # Remplacer les variables dans le template + 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)) + + # Créer log email + 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() + + # Enqueue l'email + email_queue.enqueue(email_log.id) + + logger.info(f"📧 Email de signature envoyé en file: {email}") 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}") - return {"error": str(e), "statut": "ERREUR"} + logger.error(f"❌ Erreur Universign+Email: {e}") + return {"error": str(e), "statut": "ERREUR", "email_sent": False} async def universign_statut(transaction_id: str) -> Dict: @@ -262,20 +1577,16 @@ async def universign_statut(transaction_id: str) -> Dict: return {"statut": "ERREUR", "error": str(e)} -# ===================================================== -# CYCLE DE VIE -# ===================================================== @asynccontextmanager async def lifespan(app: FastAPI): # Init base de données await init_db() logger.info("✅ Base de données initialisée") - # Injecter session_factory dans email_queue email_queue.session_factory = async_session_factory + email_queue.sage_client = sage_client - # ⚠️ PAS de sage_connector ici (c'est sur Windows !) - # email_queue utilisera sage_client pour générer les PDFs via HTTP + logger.info("✅ sage_client injecté dans email_queue") # Démarrer queue email_queue.start(num_workers=settings.max_email_workers) @@ -296,6 +1607,7 @@ app = FastAPI( version="2.0.0", description="API de gestion commerciale - VPS Linux", lifespan=lifespan, + openapi_tags=TAGS_METADATA, ) app.add_middleware( @@ -310,23 +1622,87 @@ app.add_middleware( app.include_router(auth_router) -# ===================================================== -# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) -# ===================================================== -@app.get("/clients", response_model=List[ClientResponse], tags=["US-A1"]) +@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def rechercher_clients(query: Optional[str] = Query(None)): - """🔍 Recherche clients via gateway Windows""" try: clients = sage_client.lister_clients(filtre=query or "") - return [ClientResponse(**c) for c in clients] + 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("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) +@app.get("/clients/{code}", 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 {"success": True, "data": 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: + # Appel à la gateway Windows + 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: + # 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) +): + try: + nouveau_client = sage_client.creer_client(client.dict()) + + logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}") + + return { + "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}") + # 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)) + + +@app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): - """🔍 Recherche articles via gateway Windows""" try: articles = sage_client.lister_articles(filtre=query or "") return [ArticleResponse(**a) for a in articles] @@ -335,19 +1711,150 @@ async def rechercher_articles(query: Optional[str] = Query(None)): raise HTTPException(500, str(e)) -@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"]) +@app.post( + "/articles", + response_model=ArticleResponse, + status_code=status.HTTP_201_CREATED, + tags=["Articles"], +) +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, + 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}") + + # Appel à la gateway Windows + resultat = sage_client.creer_article(article_data) + + logger.info( + f"✅ Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" + ) + + 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)) + + except HTTPException: + raise + + except Exception as e: + # Erreur technique Sage + logger.error(f"❌ Erreur technique création article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'article: {str(e)}", + ) + + +@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())}") + + # Appel à la gateway Windows + resultat = sage_client.modifier_article(reference, article_data) + + # Log spécial pour modification de stock (important pour erreur 2881) + if "stock_reel" in article_data: + logger.info( + f"📦 Stock {reference} modifié: {article_data['stock_reel']} " + f"(peut résoudre erreur 2881)" + ) + + logger.info(f"✅ Article {reference} modifié ({len(article_data)} champs)") + + return ArticleResponse(**resultat) + + except ValueError as e: + # Erreur métier (ex: article introuvable) + logger.warning(f"⚠️ Erreur métier modification article: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + # Erreur technique Sage + logger.error(f"❌ Erreur technique modification article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la modification de l'article: {str(e)}", + ) + + +@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): - """📝 Création de devis via gateway Windows""" 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, + "reference": devis.reference, "lignes": [ { "article_code": l.article_code, "quantite": l.quantite, - "prix_unitaire_ht": l.prix_unitaire_ht, "remise_pourcentage": l.remise_pourcentage, } for l in devis.lignes @@ -373,7 +1880,190 @@ async def creer_devis(devis: DevisRequest): raise HTTPException(500, str(e)) -@app.get("/devis", tags=["US-A1"]) +@app.put("/devis/{id}", tags=["Devis"]) +async def modifier_devis( + id: str, + devis_update: DevisUpdateRequest, + session: AsyncSession = Depends(get_session), +): + 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") + + # Vérifier qu'il n'est pas déjà transformé + if devis_existant.get("statut") == 5: + raise HTTPException( + 400, f"Le devis {id} a déjà été transformé et ne peut plus être modifié" + ) + + # Construire les données de mise à jour + 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": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l 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 + + # Appel à la gateway Windows + 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: + # Vérifier que le client existe + client = sage_client.lire_client(commande.client_id) + if not client: + raise HTTPException(404, f"Client {commande.client_id} introuvable") + + # Préparer les données pour la gateway + commande_data = { + "client_id": commande.client_id, + "date_commande": ( + commande.date_commande.isoformat() if commande.date_commande else None + ), + "reference": commande.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in commande.lignes + ], + } + + # Appel à la gateway Windows + 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: + # Vérifier que la commande existe + commande_existante = sage_client.lire_document( + id, TypeDocumentSQL.BON_COMMANDE + ) + + if not commande_existante: + raise HTTPException(404, f"Commande {id} introuvable") + + # Vérifier le statut + statut_actuel = commande_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La commande {id} a déjà été transformée et ne peut plus être modifiée", + ) + + if statut_actuel == 6: + raise HTTPException( + 400, f"La commande {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + 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": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l 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 + + # Appel à la gateway Windows + 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), @@ -381,16 +2071,6 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), ): - """ - 📋 Liste tous les devis via gateway Windows - - Args: - limit: Nombre maximum de devis à retourner - statut: Filtre par statut (0=Devis, 2=Accepté, 5=Transformé, etc.) - inclure_lignes: Si True, charge les lignes de chaque devis (par défaut: True) - - ✅ AMÉLIORATION: Les lignes sont maintenant incluses par défaut - """ try: devis_list = sage_client.lister_devis( limit=limit, statut=statut, inclure_lignes=inclure_lignes @@ -402,14 +2082,16 @@ async def lister_devis( raise HTTPException(500, str(e)) -@app.get("/devis/{id}", tags=["US-A1"]) +@app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): - """📄 Lecture d'un devis via gateway Windows""" try: devis = sage_client.lire_devis(id) + if not devis: raise HTTPException(404, f"Devis {id} introuvable") - return devis + + return {"success": True, "data": devis} + except HTTPException: raise except Exception as e: @@ -417,9 +2099,8 @@ async def lire_devis(id: str): raise HTTPException(500, str(e)) -@app.get("/devis/{id}/pdf", tags=["US-A1"]) +@app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): - """📄 Téléchargement PDF (généré via email_queue)""" try: # Générer PDF en appelant la méthode de email_queue # qui elle-même appellera sage_client pour récupérer les données @@ -435,11 +2116,71 @@ async def telecharger_devis_pdf(id: str): raise HTTPException(500, str(e)) -@app.post("/devis/{id}/envoyer", tags=["US-A1"]) +@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: + # Mapping des types vers les libellés + types_labels = { + 0: "Devis", + 10: "Commande", + 20: "Preparation", + 30: "BonLivraison", + 40: "BonRetour", + 50: "Avoir", + 60: "Facture", + } + + # Vérifier que le type est valide + 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})") + + # Appel à sage_client pour générer le PDF + 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") + + # Nom de fichier formaté + 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) ): - """📧 Envoi devis par email""" try: # Vérifier que le devis existe devis = sage_client.lire_devis(id) @@ -489,45 +2230,70 @@ async def envoyer_devis_email( raise HTTPException(500, str(e)) -@app.put("/devis/{id}/statut", tags=["US-A1"]) -async def changer_statut_devis(id: str, nouveau_statut: int = Query(..., ge=0, le=5)): - """ - 📄 Changement de statut d'un devis via gateway Windows - """ +@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: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) + # 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, + 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é: {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_nouveau": resultat.get("statut_nouveau"), - "message": "Statut mis à jour avec succès", + "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: {e}") + logger.error(f"Erreur changement statut devis {id}: {e}") raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) -# ===================================================== +@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=["US-A2"]) +@app.get("/commandes", tags=["Commandes"]) async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les commandes via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_commandes() qui existe déjà - Le filtrage sur type 10 est fait côté Windows dans main.py - """ try: commandes = sage_client.lister_commandes(limit=limit, statut=statut) return commandes @@ -537,19 +2303,17 @@ async def lister_commandes( raise HTTPException(500, str(e)) -@app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) +@app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Devis → Commande - ✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10) - """ 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, @@ -573,6 +2337,7 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi "document_source": id, "document_cible": resultat["document_cible"], "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, } except Exception as e: @@ -580,12 +2345,8 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) +@app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): - """ - 🔧 Transformation Commande → Facture - ✅ Utilise les VRAIS types Sage (10 → 60) - """ try: resultat = sage_client.transformer_document( numero_source=id, @@ -623,21 +2384,56 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) -# ===================================================== -@app.post("/signature/universign/send", tags=["US-A3"]) -async def envoyer_signature( +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) ): - """✍️ Envoi document pour signature Universign""" try: - # Générer PDF - pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc) + # 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") - # Envoi Universign - resultat = await universign_envoyer( - demande.doc_id, pdf_bytes, demande.email_signataire, demande.nom_signataire + # 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": { + 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")), + } + + # Envoi Universign + Email automatique + resultat = await universign_envoyer_avec_email( + 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: @@ -659,29 +2455,243 @@ async def envoyer_signature( session.add(signature_log) await session.commit() - # MAJ champ libre Sage via gateway Windows + # MAJ champ libre Sage 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}") + 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}") + logger.error(f"❌ Erreur signature: {e}") raise HTTPException(500, str(e)) -@app.get("/signature/universign/status", tags=["US-A3"]) +@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"} + + # Chercher la signature dans la DB + 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"} + + # ============================================= + # 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 = { + 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)) + + # Créer email de confirmation + 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 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}") + + 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 + + # Chercher signatures en attente depuis > 7 jours + 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: + # 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 = { + 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)) + + # Créer email de relance + 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) + + # Incrémenter compteur de relances + 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(...)): - """🔍 Récupération du statut de signature en temps réel""" # Chercher dans la DB locale try: async with async_session_factory() as session: @@ -707,13 +2717,12 @@ async def statut_signature(docId: str = Query(...)): raise HTTPException(500, str(e)) -@app.get("/signatures", tags=["US-A3"]) +@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), ): - """📋 Liste toutes les demandes de signature""" query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) if statut: @@ -745,11 +2754,10 @@ async def lister_signatures( ] -@app.get("/signatures/{transaction_id}/status", tags=["US-A3"]) +@app.get("/signatures/{transaction_id}/status", tags=["Signatures"]) async def statut_signature_detail( transaction_id: str, session: AsyncSession = Depends(get_session) ): - """🔍 Récupération du statut détaillé d'une signature""" query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id) result = await session.execute(query) signature_log = result.scalar_one_or_none() @@ -799,9 +2807,8 @@ async def statut_signature_detail( } -@app.post("/signatures/refresh-all", tags=["US-A3"]) +@app.post("/signatures/refresh-all", tags=["Signatures"]) async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): - """🔄 Rafraîchit TOUS les statuts des signatures en attente""" query = select(SignatureLog).where( SignatureLog.statut.in_( [StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE] @@ -848,11 +2855,10 @@ async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_sess } -@app.post("/devis/{id}/signer", tags=["US-A3"]) +@app.post("/devis/{id}/signer", tags=["Devis"]) async def envoyer_devis_signature( id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session) ): - """✏️ Envoi d'un devis pour signature électronique""" try: # Vérifier devis via gateway Windows devis = sage_client.lire_devis(id) @@ -904,11 +2910,6 @@ async def envoyer_devis_signature( raise HTTPException(500, str(e)) -# ============================================ -# US-A4 - ENVOI EMAILS EN LOT -# ============================================ - - class EmailBatchRequest(BaseModel): destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) sujet: str = Field(..., min_length=1, max_length=500) @@ -917,11 +2918,10 @@ class EmailBatchRequest(BaseModel): type_document: Optional[TypeDocument] = None -@app.post("/emails/send-batch", tags=["US-A4"]) +@app.post("/emails/send-batch", tags=["Emails"]) async def envoyer_emails_lot( batch: EmailBatchRequest, session: AsyncSession = Depends(get_session) ): - """📧 US-A4: Envoi groupé via email_queue""" resultats = [] for destinataire in batch.destinataires: @@ -966,21 +2966,14 @@ async def envoyer_emails_lot( } -# ===================================================== -# ENDPOINTS - US-A5 -# ===================================================== - - -@app.post("/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["US-A5"]) +@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), ): - """ - 💰 US-A5: Validation remise via barème client Sage - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) remise_max = sage_client.lire_remise_max_client(client_id) autorisee = remise_pourcentage <= remise_max @@ -1006,14 +2999,10 @@ async def valider_remise( raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A6 (RELANCE DEVIS) -# ===================================================== -@app.post("/devis/{id}/relancer-signature", tags=["US-A6"]) +@app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session) ): - """📧 Relance devis via Universign""" try: # Lire devis via gateway devis = sage_client.lire_devis(id) @@ -1080,9 +3069,8 @@ class ContactClientResponse(BaseModel): peut_etre_relance: bool -@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["US-A6"]) +@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis(id: str): - """👤 US-A6: Récupération du contact client associé au devis""" try: # Lire devis via gateway Windows devis = sage_client.lire_devis(id) @@ -1111,16 +3099,10 @@ async def recuperer_contact_devis(id: str): # ===================================================== -@app.get("/factures", tags=["US-A7"]) +@app.get("/factures", tags=["Factures"]) async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) ): - """ - 📋 Liste toutes les factures via gateway Windows - - ✅ CORRECTION : Utilise sage_client.lister_factures() qui existe déjà - Le filtrage sur type 60 est fait côté Windows dans main.py - """ try: factures = sage_client.lister_factures(limit=limit, statut=statut) return factures @@ -1130,12 +3112,150 @@ async def lister_factures( 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 -# Templates email (si pas déjà définis) +@app.post("/factures", status_code=201, tags=["Factures"]) +async def creer_facture( + facture: FactureCreateRequest, session: AsyncSession = Depends(get_session) +): + try: + # Vérifier que le client existe + client = sage_client.lire_client(facture.client_id) + if not client: + raise HTTPException(404, f"Client {facture.client_id} introuvable") + + # Préparer les données pour la gateway + facture_data = { + "client_id": facture.client_id, + "date_facture": ( + facture.date_facture.isoformat() if facture.date_facture else None + ), + "reference": facture.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in facture.lignes + ], + } + + # Appel à la gateway Windows + 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: + # Vérifier que la facture existe + facture_existante = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) + + if not facture_existante: + raise HTTPException(404, f"Facture {id} introuvable") + + # Vérifier le statut + statut_actuel = facture_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La facture {id} a déjà été transformée et ne peut plus être modifiée", + ) + + if statut_actuel == 6: + raise HTTPException( + 400, f"La facture {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + 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": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l 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 + + # Appel à la gateway Windows + 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", @@ -1159,16 +3279,15 @@ templates_email_db = { } -@app.post("/factures/{id}/relancer", tags=["US-A7"]) +@app.post("/factures/{id}/relancer", tags=["Factures"]) async def relancer_facture( id: str, relance: RelanceFactureRequest, session: AsyncSession = Depends(get_session), ): - """💸 US-A7: Relance facture en un clic""" try: # Lire facture via gateway Windows - facture = sage_client.lire_document(id, TypeDocument.FACTURE) + facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {id} introuvable") @@ -1214,7 +3333,6 @@ async def relancer_facture( # Enqueue email_queue.enqueue(email_log.id) - # ✅ MAJ champ libre via gateway Windows sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) await session.commit() @@ -1234,19 +3352,13 @@ async def relancer_facture( raise HTTPException(500, str(e)) -# ============================================ -# US-A9 - JOURNAL DES E-MAILS -# ============================================ - - -@app.get("/emails/logs", tags=["US-A9"]) +@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), ): - """📋 US-A9: Journal des e-mails envoyés""" query = select(EmailLog) if statut: @@ -1276,12 +3388,11 @@ async def journal_emails( ] -@app.get("/emails/logs/export", tags=["US-A9"]) +@app.get("/emails/logs/export", tags=["Emails"]) async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), ): - """📥 US-A9: Export CSV des logs d'envoi""" query = select(EmailLog) if statut: query = query.where(EmailLog.statut == StatutEmailEnum[statut.value]) @@ -1337,11 +3448,6 @@ async def exporter_logs_csv( ) -# ============================================ -# US-A10 - MODÈLES D'E-MAILS -# ============================================ - - class TemplateEmail(BaseModel): id: Optional[str] = None nom: str @@ -1356,26 +3462,23 @@ class TemplatePreviewRequest(BaseModel): type_document: TypeDocument -@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["US-A10"]) +@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) async def lister_templates(): - """📧 US-A10: Liste tous les templates d'emails""" return [TemplateEmail(**template) for template in templates_email_db.values()] @app.get( - "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["US-A10"] + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def lire_template(template_id: str): - """📖 Lecture d'un template par ID""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") return TemplateEmail(**templates_email_db[template_id]) -@app.post("/templates/emails", response_model=TemplateEmail, tags=["US-A10"]) +@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) async def creer_template(template: TemplateEmail): - """➕ Création d'un nouveau template""" template_id = str(uuid.uuid4()) templates_email_db[template_id] = { @@ -1392,10 +3495,9 @@ async def creer_template(template: TemplateEmail): @app.put( - "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["US-A10"] + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) async def modifier_template(template_id: str, template: TemplateEmail): - """✏️ Modification d'un template existant""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -1416,9 +3518,8 @@ async def modifier_template(template_id: str, template: TemplateEmail): return TemplateEmail(id=template_id, **template.dict()) -@app.delete("/templates/emails/{template_id}", tags=["US-A10"]) +@app.delete("/templates/emails/{template_id}", tags=["Emails"]) async def supprimer_template(template_id: str): - """🗑️ Suppression d'un template""" if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -1432,9 +3533,8 @@ async def supprimer_template(template_id: str): return {"success": True, "message": f"Template {template_id} supprimé"} -@app.post("/templates/emails/preview", tags=["US-A10"]) +@app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email(preview: TemplatePreviewRequest): - """👁️ US-A10: Prévisualisation email avec fusion variables""" if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -1476,7 +3576,6 @@ async def previsualiser_email(preview: TemplatePreviewRequest): # ===================================================== @app.get("/health", tags=["System"]) async def health_check(): - """🏥 Health check""" gateway_health = sage_client.health() return { @@ -1493,7 +3592,6 @@ async def health_check(): @app.get("/", tags=["System"]) async def root(): - """🏠 Page d'accueil""" return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", @@ -1509,11 +3607,7 @@ async def root(): @app.get("/admin/cache/info", tags=["Admin"]) async def info_cache(): - """ - 📊 Informations sur l'état du cache Windows - """ try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) cache_info = sage_client.get_cache_info() return cache_info @@ -1522,34 +3616,8 @@ async def info_cache(): raise HTTPException(500, str(e)) -# 🔧 CORRECTION ADMIN: Cache refresh (doit appeler Windows) -@app.post("/admin/cache/refresh", tags=["Admin"]) -async def forcer_actualisation(): - """ - 🔄 Force l'actualisation du cache Windows - """ - try: - # ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows) - resultat = sage_client.refresh_cache() - cache_info = sage_client.get_cache_info() - - return { - "success": True, - "message": "Cache actualisé sur Windows Server", - "info": cache_info, - } - - except Exception as e: - logger.error(f"Erreur refresh cache: {e}") - raise HTTPException(500, str(e)) - - -# ✅ RESTE INCHANGÉ: admin/queue/status (local au VPS) @app.get("/admin/queue/status", tags=["Admin"]) async def statut_queue(): - """ - 📊 Statut de la queue d'emails (local VPS) - """ return { "queue_size": email_queue.queue.qsize(), "workers": len(email_queue.workers), @@ -1557,6 +3625,960 @@ async def statut_queue(): } +# ===================================================== +# ENDPOINTS - PROSPECTS +# ===================================================== +@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)) + + +# ===================================================== +# ENDPOINTS - FOURNISSEURS +# ===================================================== +@app.get("/fournisseurs", 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 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: + # 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')}") + + return { + "success": True, + "message": "Fournisseur créé avec succès", + "data": nouveau_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)) + + +@app.put("/fournisseurs/{code}", tags=["Fournisseurs"]) +async def modifier_fournisseur( + code: str, + fournisseur_update: FournisseurUpdateRequest, + session: AsyncSession = Depends(get_session), +): + try: + # Appel à la gateway Windows + resultat = sage_client.modifier_fournisseur( + code, fournisseur_update.dict(exclude_none=True) + ) + + logger.info(f"✅ Fournisseur {code} modifié avec succès") + + return { + "success": True, + "message": f"Fournisseur {code} modifié avec succès", + "fournisseur": resultat, + } + + 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)) + + +@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)) + + +# ===================================================== +# ENDPOINTS - AVOIRS +# ===================================================== +@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: + # Vérifier que le client existe + client = sage_client.lire_client(avoir.client_id) + if not client: + raise HTTPException(404, f"Client {avoir.client_id} introuvable") + + # Préparer les données pour la gateway + avoir_data = { + "client_id": avoir.client_id, + "date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None), + "reference": avoir.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in avoir.lignes + ], + } + + # Appel à la gateway Windows + 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: + # Vérifier que l'avoir existe + avoir_existant = sage_client.lire_avoir(id) + + if not avoir_existant: + raise HTTPException(404, f"Avoir {id} introuvable") + + # Vérifier le statut + statut_actuel = avoir_existant.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, f"L'avoir {id} a déjà été transformé et ne peut plus être modifié" + ) + + if statut_actuel == 6: + raise HTTPException( + 400, f"L'avoir {id} est annulé et ne peut plus être modifié" + ) + + # Construire les données de mise à jour + 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": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l 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 + + # Appel à la gateway Windows + 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)) + + +# ===================================================== +# ENDPOINTS - LIVRAISONS +# ===================================================== +@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: + # Vérifier que le client existe + client = sage_client.lire_client(livraison.client_id) + if not client: + raise HTTPException(404, f"Client {livraison.client_id} introuvable") + + # Préparer les données pour la gateway + livraison_data = { + "client_id": livraison.client_id, + "date_livraison": ( + livraison.date_livraison.isoformat() + if livraison.date_livraison + else None + ), + "reference": livraison.reference, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l in livraison.lignes + ], + } + + # Appel à la gateway Windows + 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: + # Vérifier que la livraison existe + livraison_existante = sage_client.lire_livraison(id) + + if not livraison_existante: + raise HTTPException(404, f"Livraison {id} introuvable") + + # Vérifier le statut + statut_actuel = livraison_existante.get("statut", 0) + + if statut_actuel == 5: + raise HTTPException( + 400, + f"La livraison {id} a déjà été transformée et ne peut plus être modifiée", + ) + + if statut_actuel == 6: + raise HTTPException( + 400, f"La livraison {id} est annulée et ne peut plus être modifiée" + ) + + # Construire les données de mise à jour + 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": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage, + } + for l 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 + + # Appel à la gateway Windows + 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: + # É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") + + 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.", + ) + + # É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, + 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: + # Étape 1: Vérifier que la commande existe + 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.", + ) + + # É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, + 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: + # Validation des données + if not famille.code or not famille.intitule: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'code' et 'intitule' sont obligatoires", + ) + + famille_data = famille.dict() + + logger.info(f"📦 Création famille: {famille.code} - {famille.intitule}") + + # Appel à la gateway Windows + resultat = sage_client.creer_famille(famille_data) + + logger.info(f"✅ Famille créée: {resultat.get('code')}") + + return FamilleResponse(**resultat) + + except ValueError as e: + # Erreur métier (ex: famille existe déjà) + logger.warning(f"⚠️ Erreur métier création famille: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + # Erreur technique Sage + logger.error(f"❌ Erreur technique création famille: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la famille: {str(e)}", + ) + + +@app.post( + "/stock/entree", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock", +) +async def creer_entree_stock(entree: EntreeStockRequest): + try: + # Préparer les données + entree_data = entree.dict() + 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')}") + + return MouvementStockResponse(**resultat) + + except ValueError as e: + logger.warning(f"⚠️ Erreur métier entrée stock: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except Exception as e: + logger.error(f"❌ Erreur technique entrée stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'entrée: {str(e)}", + ) + + +@app.post( + "/stock/sortie", + response_model=MouvementStockResponse, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="SORTIE DE STOCK : Retire des articles du stock", +) +async def creer_sortie_stock(sortie: SortieStockRequest): + try: + # Préparer les données + sortie_data = sortie.dict() + 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')}") + + 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: + # 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) + + # 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( + 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 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_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_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()} + + 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: + # Récupérer le PDF (en bytes) + pdf_bytes = sage_client.generer_pdf_document( + numero=numero, + type_doc=type_doc, + modele=modele, + base64_encode=False, # On veut les bytes bruts + ) + + # Retourner le PDF + 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)) + + # ===================================================== # LANCEMENT # ===================================================== diff --git a/config.py b/config.py index bc36e64..1b3125e 100644 --- a/config.py +++ b/config.py @@ -6,8 +6,8 @@ class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - - # === JWT & Auth === + + # === JWT & Auth === jwt_secret: str jwt_algorithm: str access_token_expire_minutes: int @@ -27,7 +27,7 @@ class Settings(BaseSettings): frontend_url: str # === Base de données === - database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" + database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db" # === SMTP === smtp_host: str diff --git a/core/dependencies.py b/core/dependencies.py index a860f5c..7f8a5f9 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -5,24 +5,25 @@ from sqlalchemy import select from database import get_session, User from security.auth import decode_token from typing import Optional +from datetime import datetime # ✅ AJOUT MANQUANT - C'ÉTAIT LE BUG ! security = HTTPBearer() async def get_current_user( credentials: HTTPAuthorizationCredentials = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> User: """ Dépendance FastAPI pour extraire l'utilisateur du JWT - + Usage dans un endpoint: @app.get("/protected") async def protected_route(user: User = Depends(get_current_user)): return {"user_id": user.id} """ token = credentials.credentials - + # Décoder le token payload = decode_token(token) if not payload: @@ -31,7 +32,7 @@ async def get_current_user( detail="Token invalide ou expiré", headers={"WWW-Authenticate": "Bearer"}, ) - + # Vérifier le type if payload.get("type") != "access": raise HTTPException( @@ -39,7 +40,7 @@ async def get_current_user( detail="Type de token incorrect", headers={"WWW-Authenticate": "Bearer"}, ) - + # Extraire user_id user_id: str = payload.get("sub") if not user_id: @@ -48,46 +49,43 @@ async def get_current_user( detail="Token malformé", headers={"WWW-Authenticate": "Bearer"}, ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Utilisateur introuvable", headers={"WWW-Authenticate": "Bearer"}, ) - + # Vérifications de sécurité if not user.is_active: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" ) - + if not user.is_verified: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - - # Vérifier si le compte est verrouillé + + # ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) if user.locked_until and user.locked_until > datetime.now(): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées" + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", ) - + return user async def get_current_user_optional( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ) -> Optional[User]: """ Version optionnelle - ne lève pas d'erreur si pas de token @@ -95,7 +93,7 @@ async def get_current_user_optional( """ if not credentials: return None - + try: return await get_current_user(credentials, session) except HTTPException: @@ -105,18 +103,19 @@ async def get_current_user_optional( def require_role(*allowed_roles: str): """ Décorateur pour restreindre l'accès par rôle - + Usage: @app.get("/admin/users") async def list_users(user: User = Depends(require_role("admin"))): ... """ + async def role_checker(user: User = Depends(get_current_user)) -> User: if user.role not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}" + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", ) return user - - return role_checker \ No newline at end of file + + return role_checker diff --git a/create_admin.py b/create_admin.py index a85b4df..41f11b7 100644 --- a/create_admin.py +++ b/create_admin.py @@ -25,29 +25,31 @@ logger = logging.getLogger(__name__) async def create_admin(): """Crée un utilisateur admin""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🔐 Création d'un compte administrateur") - print("="*60 + "\n") - + print("=" * 60 + "\n") + # Saisie des informations email = input("Email de l'admin: ").strip().lower() - if not email or '@' not in email: + if not email or "@" not in email: print("❌ Email invalide") return False - + prenom = input("Prénom: ").strip() nom = input("Nom: ").strip() - + if not prenom or not nom: print("❌ Prénom et nom requis") return False - + # Mot de passe avec validation while True: - password = input("Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): ") + password = input( + "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " + ) is_valid, error_msg = validate_password_strength(password) - + if is_valid: confirm = input("Confirmez le mot de passe: ") if password == confirm: @@ -56,20 +58,18 @@ async def create_admin(): print("❌ Les mots de passe ne correspondent pas\n") else: print(f"❌ {error_msg}\n") - + # Vérifier si l'email existe déjà async with async_session_factory() as session: from sqlalchemy import select - - result = await session.execute( - select(User).where(User.email == email) - ) + + result = await session.execute(select(User).where(User.email == email)) existing = result.scalar_one_or_none() - + if existing: print(f"\n❌ Un utilisateur avec l'email {email} existe déjà") return False - + # Créer l'admin admin = User( id=str(uuid.uuid4()), @@ -80,19 +80,19 @@ async def create_admin(): role="admin", is_verified=True, # Admin vérifié par défaut is_active=True, - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(admin) await session.commit() - + print("\n✅ Administrateur créé avec succès!") print(f"📧 Email: {email}") print(f"👤 Nom: {prenom} {nom}") print(f"🔑 Rôle: admin") print(f"🆔 ID: {admin.id}") print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") - + return True @@ -106,4 +106,4 @@ if __name__ == "__main__": except Exception as e: print(f"\n❌ Erreur: {e}") logger.exception("Détails:") - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/data/sage_dataven.db b/data/sage_dataven.db new file mode 100644 index 0000000..2550603 Binary files /dev/null and b/data/sage_dataven.db differ diff --git a/database/__init__.py b/database/__init__.py index 0e2957a..579c644 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -3,7 +3,7 @@ from database.db_config import ( async_session_factory, init_db, get_session, - close_db + close_db, ) from database.models import ( @@ -23,26 +23,23 @@ from database.models import ( __all__ = [ # Config - 'engine', - 'async_session_factory', - 'init_db', - 'get_session', - 'close_db', - + "engine", + "async_session_factory", + "init_db", + "get_session", + "close_db", # Models existants - 'Base', - 'EmailLog', - 'SignatureLog', - 'WorkflowLog', - 'CacheMetadata', - 'AuditLog', - + "Base", + "EmailLog", + "SignatureLog", + "WorkflowLog", + "CacheMetadata", + "AuditLog", # Enums - 'StatutEmail', - 'StatutSignature', - + "StatutEmail", + "StatutSignature", # Modèles auth - 'User', - 'RefreshToken', - 'LoginAttempt', -] \ No newline at end of file + "User", + "RefreshToken", + "LoginAttempt", +] diff --git a/database/db_config.py b/database/db_config.py index 0bbba98..f5bc0b4 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -6,7 +6,7 @@ import logging logger = logging.getLogger(__name__) -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db") +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/sage_dataven.db") engine = create_async_engine( DATABASE_URL, @@ -32,10 +32,10 @@ async def init_db(): try: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) - + logger.info("✅ Base de données initialisée avec succès") logger.info(f"📍 Fichier DB: {DATABASE_URL}") - + except Exception as e: logger.error(f"❌ Erreur initialisation DB: {e}") raise @@ -53,4 +53,4 @@ async def get_session() -> AsyncSession: async def close_db(): """Ferme proprement toutes les connexions""" await engine.dispose() - logger.info("🔌 Connexions DB fermées") \ No newline at end of file + logger.info("🔌 Connexions DB fermées") diff --git a/database/models.py b/database/models.py index 2c260ef..da8c7b2 100644 --- a/database/models.py +++ b/database/models.py @@ -1,4 +1,13 @@ -from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Boolean, Enum as SQLEnum +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, + Enum as SQLEnum, +) from sqlalchemy.ext.declarative import declarative_base from datetime import datetime import enum @@ -9,8 +18,10 @@ Base = declarative_base() # Enums # ============================================================================ + class StatutEmail(str, enum.Enum): """Statuts possibles d'un email""" + EN_ATTENTE = "EN_ATTENTE" EN_COURS = "EN_COURS" ENVOYE = "ENVOYE" @@ -18,54 +29,59 @@ class StatutEmail(str, enum.Enum): ERREUR = "ERREUR" BOUNCE = "BOUNCE" + class StatutSignature(str, enum.Enum): """Statuts possibles d'une signature électronique""" + EN_ATTENTE = "EN_ATTENTE" ENVOYE = "ENVOYE" SIGNE = "SIGNE" REFUSE = "REFUSE" EXPIRE = "EXPIRE" + # ============================================================================ # Tables # ============================================================================ + class EmailLog(Base): """ Journal des emails envoyés via l'API Permet le suivi et le retry automatique """ + __tablename__ = "email_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Destinataires destinataire = Column(String(255), nullable=False, index=True) cc = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié - + # Contenu sujet = Column(String(500), nullable=False) corps_html = Column(Text, nullable=False) - + # Documents attachés document_ids = Column(Text, nullable=True) # Séparés par virgules type_document = Column(Integer, nullable=True) - + # Statut statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) - + # Tracking temporel date_creation = Column(DateTime, default=datetime.now, nullable=False) date_envoi = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True) - + # Retry automatique nb_tentatives = Column(Integer, default=0) derniere_erreur = Column(Text, nullable=True) prochain_retry = Column(DateTime, nullable=True) - + # Métadonnées ip_envoi = Column(String(45), nullable=True) user_agent = Column(String(500), nullable=True) @@ -79,33 +95,37 @@ class SignatureLog(Base): Journal des demandes de signature Universign Permet le suivi du workflow de signature """ + __tablename__ = "signature_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Document Sage associé document_id = Column(String(100), nullable=False, index=True) type_document = Column(Integer, nullable=False) - + # Universign transaction_id = Column(String(100), unique=True, index=True, nullable=True) signer_url = Column(String(500), nullable=True) - + # Signataire email_signataire = Column(String(255), nullable=False, index=True) nom_signataire = Column(String(255), nullable=False) - + # Statut - statut = Column(SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True) + statut = Column( + SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True + ) date_envoi = Column(DateTime, default=datetime.now) date_signature = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True) - + # Relances est_relance = Column(Boolean, default=False) nb_relances = Column(Integer, default=0) - + derniere_relance = Column(DateTime, nullable=True) + # Métadonnées raison_refus = Column(Text, nullable=True) ip_signature = Column(String(45), nullable=True) @@ -119,27 +139,28 @@ class WorkflowLog(Base): Journal des transformations de documents (Devis → Commande → Facture) Permet la traçabilité du workflow commercial """ + __tablename__ = "workflow_logs" - + # Identifiant id = Column(String(36), primary_key=True) - + # Documents document_source = Column(String(100), nullable=False, index=True) type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. - + document_cible = Column(String(100), nullable=False, index=True) type_cible = Column(Integer, nullable=False) - + # Métadonnées de transformation nb_lignes = Column(Integer, nullable=True) montant_ht = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True) - + # Tracking date_transformation = Column(DateTime, default=datetime.now, nullable=False) utilisateur = Column(String(100), nullable=True) - + # Résultat succes = Column(Boolean, default=True) erreur = Column(Text, nullable=True) @@ -154,18 +175,21 @@ class CacheMetadata(Base): Métadonnées sur le cache Sage (clients, articles) Permet le monitoring du cache géré par la gateway Windows """ + __tablename__ = "cache_metadata" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Type de cache - cache_type = Column(String(50), unique=True, nullable=False) # 'clients' ou 'articles' - + cache_type = Column( + String(50), unique=True, nullable=False + ) # 'clients' ou 'articles' + # Statistiques last_refresh = Column(DateTime, default=datetime.now) item_count = Column(Integer, default=0) refresh_duration_ms = Column(Float, nullable=True) - + # Santé last_error = Column(Text, nullable=True) error_count = Column(Integer, default=0) @@ -179,66 +203,72 @@ class AuditLog(Base): Journal d'audit pour la sécurité et la conformité Trace toutes les actions importantes dans l'API """ + __tablename__ = "audit_logs" - + id = Column(Integer, primary_key=True, autoincrement=True) - + # Action - action = Column(String(100), nullable=False, index=True) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. + action = Column( + String(100), nullable=False, index=True + ) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc. ressource_id = Column(String(100), nullable=True, index=True) - + # Utilisateur (si authentification ajoutée plus tard) utilisateur = Column(String(100), nullable=True) ip_address = Column(String(45), nullable=True) - + # Résultat succes = Column(Boolean, default=True) details = Column(Text, nullable=True) # JSON stringifié erreur = Column(Text, nullable=True) - + # Timestamp date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) def __repr__(self): return f"" - + + # Ajouter ces modèles à la fin de database/models.py + class User(Base): """ Utilisateurs de l'API avec validation email """ + __tablename__ = "users" - + id = Column(String(36), primary_key=True) email = Column(String(255), unique=True, nullable=False, index=True) hashed_password = Column(String(255), nullable=False) - + # Profil nom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False) role = Column(String(50), default="user") # user, admin, commercial - + # Validation email is_verified = Column(Boolean, default=False) verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token_expires = Column(DateTime, nullable=True) - + # Sécurité is_active = Column(Boolean, default=True) failed_login_attempts = Column(Integer, default=0) locked_until = Column(DateTime, nullable=True) - + # Mot de passe oublié reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token_expires = Column(DateTime, nullable=True) - + # Timestamps created_at = Column(DateTime, default=datetime.now, nullable=False) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_login = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -247,24 +277,25 @@ class RefreshToken(Base): """ Tokens de rafraîchissement JWT """ + __tablename__ = "refresh_tokens" - + id = Column(String(36), primary_key=True) user_id = Column(String(36), nullable=False, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True) - + # Métadonnées device_info = Column(String(500), nullable=True) ip_address = Column(String(45), nullable=True) - + # Expiration expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False) - + # Révocation is_revoked = Column(Boolean, default=False) revoked_at = Column(DateTime, nullable=True) - + def __repr__(self): return f"" @@ -273,18 +304,19 @@ class LoginAttempt(Base): """ Journal des tentatives de connexion (détection bruteforce) """ + __tablename__ = "login_attempts" - + id = Column(Integer, primary_key=True, autoincrement=True) - + email = Column(String(255), nullable=False, index=True) ip_address = Column(String(45), nullable=False, index=True) user_agent = Column(String(500), nullable=True) - + success = Column(Boolean, default=False) failure_reason = Column(String(255), nullable=True) - + timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) - + def __repr__(self): - return f"" \ No newline at end of file + return f"" diff --git a/docker-compose.yml b/docker-compose.yml index 3787019..e9ee1bc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,6 @@ services: container_name: vps-sage-api env_file: .env volumes: - # ✅ Monter un DOSSIER entier au lieu d'un fichier - ./data:/app/data ports: - "8000:8000" diff --git a/email_queue.py b/email_queue.py index 9c8451d..53d65af 100644 --- a/email_queue.py +++ b/email_queue.py @@ -25,67 +25,65 @@ class EmailQueue: """ Queue d'emails avec workers threadés et retry automatique """ - + def __init__(self): self.queue = queue.Queue() self.workers = [] self.running = False self.session_factory = None - self.sage_client = None # Sera injecté depuis api.py - + self.sage_client = None + def start(self, num_workers: int = 3): """Démarre les workers""" if self.running: logger.warning("Queue déjà démarrée") return - + self.running = True for i in range(num_workers): worker = threading.Thread( - target=self._worker, - name=f"EmailWorker-{i}", - daemon=True + target=self._worker, name=f"EmailWorker-{i}", daemon=True ) worker.start() self.workers.append(worker) - + logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)") - + def stop(self): """Arrête les workers proprement""" logger.info("🛑 Arrêt de la queue email...") self.running = False - + # Attendre que la queue soit vide (max 30s) try: self.queue.join() logger.info("✅ Queue email arrêtée proprement") except: logger.warning("⚠️ Timeout lors de l'arrêt de la queue") - + def enqueue(self, email_log_id: str): """Ajoute un email dans la queue""" self.queue.put(email_log_id) logger.debug(f"📨 Email {email_log_id} ajouté à la queue") - + def _worker(self): """Worker qui traite les emails dans un thread""" # Créer une event loop pour ce thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: while self.running: try: # Récupérer un email de la queue (timeout 1s) email_log_id = self.queue.get(timeout=1) - + # Traiter l'email loop.run_until_complete(self._process_email(email_log_id)) - + # Marquer comme traité self.queue.task_done() - + except queue.Empty: continue except Exception as e: @@ -96,144 +94,147 @@ class EmailQueue: pass finally: loop.close() - + async def _process_email(self, email_log_id: str): """Traite un email avec retry automatique""" from database import EmailLog, StatutEmail from sqlalchemy import select - + if not self.session_factory: logger.error("❌ session_factory non configuré") return - + async with self.session_factory() as session: # Charger l'email log result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) email_log = result.scalar_one_or_none() - + if not email_log: logger.error(f"❌ Email log {email_log_id} introuvable") return - + # Marquer comme en cours email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() - + try: # Envoi avec retry automatique await self._send_with_retry(email_log) - + # Succès email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None logger.info(f"✅ Email envoyé: {email_log.destinataire}") - + except Exception as e: # Échec email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - + # Programmer un retry si < max attempts if email_log.nb_tentatives < settings.max_retry_attempts: - delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1)) + delay = settings.retry_delay_seconds * ( + 2 ** (email_log.nb_tentatives - 1) + ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) - + # Programmer le retry timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() - - logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}") + + logger.warning( + f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}" + ) else: logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}") - + await session.commit() - + @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=4, max=10) + stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10) ) async def _send_with_retry(self, email_log): """Envoi SMTP avec retry Tenacity + génération PDF""" # Préparer le message msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = email_log.destinataire - msg['Subject'] = email_log.sujet - + msg["From"] = settings.smtp_from + msg["To"] = email_log.destinataire + msg["Subject"] = email_log.sujet + # Corps HTML - msg.attach(MIMEText(email_log.corps_html, 'html')) - + msg.attach(MIMEText(email_log.corps_html, "html")) + # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs if email_log.document_ids: - document_ids = email_log.document_ids.split(',') + document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document - + for doc_id in document_ids: doc_id = doc_id.strip() if not doc_id: continue - + try: # Générer PDF (appel bloquant dans thread séparé) pdf_bytes = await asyncio.to_thread( - self._generate_pdf, - doc_id, - type_doc + self._generate_pdf, doc_id, type_doc ) - + if pdf_bytes: # Attacher PDF part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") - part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"' + part["Content-Disposition"] = ( + f'attachment; filename="{doc_id}.pdf"' + ) msg.attach(part) logger.info(f"📎 PDF attaché: {doc_id}.pdf") - + except Exception as e: logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") # Continuer avec les autres PDFs - + # Envoi SMTP (bloquant mais dans thread séparé) await asyncio.to_thread(self._send_smtp, msg) - + def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: """ Génération PDF via ReportLab + sage_client - + ⚠️ Cette méthode est appelée depuis un thread worker """ from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas from reportlab.lib.units import cm from io import BytesIO - + if not self.sage_client: logger.error("❌ sage_client non configuré") raise Exception("sage_client non disponible") - + # 📡 Récupérer document depuis gateway Windows via HTTP try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: logger.error(f"❌ Erreur récupération document {doc_id}: {e}") raise Exception(f"Document {doc_id} inaccessible") - + if not doc: raise Exception(f"Document {doc_id} introuvable") - + # 📄 Créer PDF avec ReportLab buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - + # === EN-TÊTE === pdf.setFont("Helvetica-Bold", 20) - pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}") - + pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}") + # Type de document type_labels = { 0: "DEVIS", @@ -241,101 +242,105 @@ class EmailQueue: 2: "BON DE RETOUR", 3: "COMMANDE", 4: "PRÉPARATION", - 5: "FACTURE" + 5: "FACTURE", } type_label = type_labels.get(type_doc, "DOCUMENT") - + pdf.setFont("Helvetica", 12) - pdf.drawString(2*cm, height - 4*cm, f"Type: {type_label}") - + pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}") + # === INFORMATIONS CLIENT === - y = height - 5*cm + y = height - 5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "CLIENT") - - y -= 0.8*cm + pdf.drawString(2 * cm, y, "CLIENT") + + y -= 0.8 * cm pdf.setFont("Helvetica", 11) - pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Date: {doc.get('date', '')}") - + pdf.drawString(2 * cm, y, f"Code: {doc.get('client_code', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule', '')}") + y -= 0.6 * cm + pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}") + # === LIGNES === - y -= 1.5*cm + y -= 1.5 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "ARTICLES") - - y -= 1*cm + pdf.drawString(2 * cm, y, "ARTICLES") + + y -= 1 * cm pdf.setFont("Helvetica-Bold", 10) - pdf.drawString(2*cm, y, "Désignation") - pdf.drawString(10*cm, y, "Qté") - pdf.drawString(12*cm, y, "Prix Unit.") - pdf.drawString(15*cm, y, "Total HT") - - y -= 0.5*cm - pdf.line(2*cm, y, width - 2*cm, y) - - y -= 0.7*cm + pdf.drawString(2 * cm, y, "Désignation") + pdf.drawString(10 * cm, y, "Qté") + pdf.drawString(12 * cm, y, "Prix Unit.") + pdf.drawString(15 * cm, y, "Total HT") + + y -= 0.5 * cm + pdf.line(2 * cm, y, width - 2 * cm, y) + + y -= 0.7 * cm pdf.setFont("Helvetica", 9) - - for ligne in doc.get('lignes', []): + + for ligne in doc.get("lignes", []): # Nouvelle page si nécessaire - if y < 3*cm: + if y < 3 * cm: pdf.showPage() - y = height - 3*cm + y = height - 3 * cm pdf.setFont("Helvetica", 9) - - designation = ligne.get('designation', '')[:50] - pdf.drawString(2*cm, y, designation) - pdf.drawString(10*cm, y, str(ligne.get('quantite', 0))) - pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") - pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}€") - y -= 0.6*cm - + + designation = ligne.get("designation", "")[:50] + pdf.drawString(2 * cm, y, designation) + pdf.drawString(10 * cm, y, str(ligne.get("quantite", 0))) + pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") + pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}€") + y -= 0.6 * cm + # === TOTAUX === - y -= 1*cm - pdf.line(12*cm, y, width - 2*cm, y) - - y -= 0.8*cm + y -= 1 * cm + pdf.line(12 * cm, y, width - 2 * cm, y) + + y -= 0.8 * cm pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(12*cm, y, "Total HT:") - pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€") - - y -= 0.6*cm - pdf.drawString(12*cm, y, "TVA (20%):") - tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0) - pdf.drawString(15*cm, y, f"{tva:.2f}€") - - y -= 0.6*cm + pdf.drawString(12 * cm, y, "Total HT:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ht', 0):.2f}€") + + y -= 0.6 * cm + pdf.drawString(12 * cm, y, "TVA (20%):") + tva = doc.get("total_ttc", 0) - doc.get("total_ht", 0) + pdf.drawString(15 * cm, y, f"{tva:.2f}€") + + y -= 0.6 * cm pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(12*cm, y, "Total TTC:") - pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€") - + pdf.drawString(12 * cm, y, "Total TTC:") + pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}€") + # === PIED DE PAGE === pdf.setFont("Helvetica", 8) - pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}") - pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven") - + pdf.drawString( + 2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}" + ) + pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven") + # Finaliser pdf.save() buffer.seek(0) - + logger.info(f"✅ PDF généré: {doc_id}.pdf") return buffer.read() - + def _send_smtp(self, msg): """Envoi SMTP bloquant (appelé depuis asyncio.to_thread)""" try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + except smtplib.SMTPException as e: raise Exception(f"Erreur SMTP: {str(e)}") except Exception as e: @@ -343,4 +348,4 @@ class EmailQueue: # Instance globale -email_queue = EmailQueue() \ No newline at end of file +email_queue = EmailQueue() diff --git a/init_db.py b/init_db.py index b59d822..7f5c174 100644 --- a/init_db.py +++ b/init_db.py @@ -23,35 +23,35 @@ logger = logging.getLogger(__name__) async def main(): """Crée toutes les tables dans sage_dataven.db""" - - print("\n" + "="*60) + + print("\n" + "=" * 60) print("🚀 Initialisation de la base de données Sage Dataven") - print("="*60 + "\n") - + print("=" * 60 + "\n") + try: # Créer les tables await init_db() - + print("\n✅ Base de données créée avec succès!") print(f"📍 Fichier: sage_dataven.db") - + print("\n📊 Tables créées:") print(" ├─ email_logs (Journalisation emails)") print(" ├─ signature_logs (Suivi signatures Universign)") print(" ├─ workflow_logs (Transformations documents)") print(" ├─ cache_metadata (Métadonnées cache)") print(" └─ audit_logs (Journal d'audit)") - + print("\n📝 Prochaines étapes:") print(" 1. Configurer le fichier .env avec vos credentials") print(" 2. Lancer la gateway Windows sur la machine Sage") print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000") print(" 4. Ou avec Docker: docker-compose up -d") print(" 5. Tester: http://votre-vps:8000/docs") - - print("\n" + "="*60 + "\n") + + print("\n" + "=" * 60 + "\n") return True - + except Exception as e: print(f"\n❌ Erreur lors de l'initialisation: {e}") logger.exception("Détails de l'erreur:") @@ -60,4 +60,4 @@ async def main(): if __name__ == "__main__": result = asyncio.run(main()) - sys.exit(0 if result else 1) \ No newline at end of file + sys.exit(0 if result else 1) diff --git a/routes/auth.py b/routes/auth.py index 961f1c3..3d682e0 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -16,7 +16,7 @@ from security.auth import ( decode_token, generate_verification_token, generate_reset_token, - hash_token + hash_token, ) from services.email_service import AuthEmailService from core.dependencies import get_current_user @@ -29,6 +29,7 @@ router = APIRouter(prefix="/auth", tags=["Authentication"]) # === MODÈLES PYDANTIC === + class RegisterRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=8) @@ -45,7 +46,7 @@ class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" - expires_in: int = 1800 # 30 minutes en secondes + expires_in: int = 86400 # 30 minutes en secondes class RefreshTokenRequest(BaseModel): @@ -71,13 +72,14 @@ class ResendVerificationRequest(BaseModel): # === UTILITAIRES === + async def log_login_attempt( session: AsyncSession, email: str, ip: str, user_agent: str, success: bool, - failure_reason: Optional[str] = None + failure_reason: Optional[str] = None, ): """Enregistre une tentative de connexion""" attempt = LoginAttempt( @@ -86,76 +88,72 @@ async def log_login_attempt( user_agent=user_agent, success=success, failure_reason=failure_reason, - timestamp=datetime.now() + timestamp=datetime.now(), ) session.add(attempt) await session.commit() -async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[bool, str]: +async def check_rate_limit( + session: AsyncSession, email: str, ip: str +) -> tuple[bool, str]: """ Vérifie si l'utilisateur/IP est rate limité - + Returns: (is_allowed, error_message) """ # Vérifier les tentatives échouées des 15 dernières minutes time_window = datetime.now() - timedelta(minutes=15) - + result = await session.execute( - select(LoginAttempt) - .where( + select(LoginAttempt).where( LoginAttempt.email == email, LoginAttempt.success == False, - LoginAttempt.timestamp >= time_window + LoginAttempt.timestamp >= time_window, ) ) failed_attempts = result.scalars().all() - + if len(failed_attempts) >= 5: return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." - + return True, "" # === ENDPOINTS === + @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( data: RegisterRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 📝 Inscription d'un nouvel utilisateur - + - Valide le mot de passe - Crée le compte (non vérifié) - Envoie email de vérification """ # Vérifier si l'email existe déjà - result = await session.execute( - select(User).where(User.email == data.email) - ) + result = await session.execute(select(User).where(User.email == data.email)) existing_user = result.scalar_one_or_none() - + if existing_user: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Cet email est déjà utilisé" + status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé" ) - + # Valider le mot de passe is_valid, error_msg = validate_password_strength(data.password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Générer token de vérification verification_token = generate_verification_token() - + # Créer l'utilisateur new_user = User( id=str(uuid.uuid4()), @@ -166,80 +164,72 @@ async def register( is_verified=False, verification_token=verification_token, verification_token_expires=datetime.now() + timedelta(hours=24), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(new_user) await session.commit() - + # Envoyer email de vérification - base_url = str(request.base_url).rstrip('/') + base_url = str(request.base_url).rstrip("/") email_sent = AuthEmailService.send_verification_email( - data.email, - verification_token, - base_url + data.email, verification_token, base_url ) - + if not email_sent: logger.warning(f"Échec envoi email vérification pour {data.email}") - + logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") - + return { "success": True, "message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.", "user_id": new_user.id, - "email": data.email + "email": data.email, } @router.get("/verify-email") -async def verify_email_get( - token: str, - session: AsyncSession = Depends(get_session) -): +async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): """ ✅ Vérification de l'email via lien cliquable (GET) Utilisé quand l'utilisateur clique sur le lien dans l'email """ - result = await session.execute( - select(User).where(User.verification_token == token) - ) + result = await session.execute(select(User).where(User.verification_token == token)) user = result.scalar_one_or_none() - + if not user: return { "success": False, - "message": "Token de vérification invalide ou déjà utilisé." + "message": "Token de vérification invalide ou déjà utilisé.", } - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): return { "success": False, "message": "Token expiré. Veuillez demander un nouvel email de vérification.", - "expired": True + "expired": True, } - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, "message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", - "email": user.email + "email": user.email, } @router.post("/verify-email") async def verify_email_post( - data: VerifyEmailRequest, - session: AsyncSession = Depends(get_session) + data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) ): """ ✅ Vérification de l'email via API (POST) @@ -249,31 +239,31 @@ async def verify_email_post( select(User).where(User.verification_token == data.token) ) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de vérification invalide" + detail="Token de vérification invalide", ) - + # Vérifier l'expiration if user.verification_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouvel email de vérification." + detail="Token expiré. Demandez un nouvel email de vérification.", ) - + # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() - + logger.info(f"✅ Email vérifié: {user.email}") - + return { "success": True, - "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter." + "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", } @@ -281,135 +271,134 @@ async def verify_email_post( async def resend_verification( data: ResendVerificationRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔄 Renvoyer l'email de vérification """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + if not user: # Ne pas révéler si l'utilisateur existe return { "success": True, - "message": "Si cet email existe, un nouveau lien de vérification a été envoyé." + "message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", } - + if user.is_verified: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Ce compte est déjà vérifié" + status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié" ) - + # Générer nouveau token verification_token = generate_verification_token() user.verification_token = verification_token user.verification_token_expires = datetime.now() + timedelta(hours=24) await session.commit() - + # Envoyer email - base_url = str(request.base_url).rstrip('/') - AuthEmailService.send_verification_email( - user.email, - verification_token, - base_url - ) - - return { - "success": True, - "message": "Un nouveau lien de vérification a été envoyé." - } + base_url = str(request.base_url).rstrip("/") + AuthEmailService.send_verification_email(user.email, verification_token, base_url) + + return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."} @router.post("/login", response_model=TokenResponse) async def login( - data: LoginRequest, - request: Request, - session: AsyncSession = Depends(get_session) + data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) ): """ 🔐 Connexion utilisateur - + Retourne access_token (30min) et refresh_token (7 jours) """ ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") - + # Rate limiting is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) if not is_allowed: raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=error_msg + status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) - + # Charger l'utilisateur - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Vérifications if not user or not verify_password(data.password, user.hashed_password): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Identifiants incorrects") - + await log_login_attempt( + session, + data.email.lower(), + ip, + user_agent, + False, + "Identifiants incorrects", + ) + # Incrémenter compteur échecs if user: user.failed_login_attempts += 1 - + # Verrouiller après 5 échecs if user.failed_login_attempts >= 5: user.locked_until = datetime.now() + timedelta(minutes=15) await session.commit() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes." + detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.", ) - + await session.commit() - + raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Email ou mot de passe incorrect" + detail="Email ou mot de passe incorrect", ) - + # Vérifier statut compte if not user.is_active: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte désactivé") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte désactivé" + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte désactivé" ) - + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + if not user.is_verified: - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Email non vérifié") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Email non vérifié" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception." + detail="Email non vérifié. Consultez votre boîte de réception.", ) - + # Vérifier verrouillage if user.locked_until and user.locked_until > datetime.now(): - await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte verrouillé") + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" + ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé" + detail="Compte temporairement verrouillé", ) - + # ✅ CONNEXION RÉUSSIE - + # Réinitialiser compteur échecs user.failed_login_attempts = 0 user.locked_until = None user.last_login = datetime.now() - + # Créer tokens - access_token = create_access_token({"sub": user.id, "email": user.email, "role": user.role}) + access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) refresh_token_jwt = create_refresh_token(user.id) - + # Stocker refresh token en DB (hashé) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), @@ -418,28 +407,27 @@ async def login( device_info=user_agent[:500], ip_address=ip, expires_at=datetime.now() + timedelta(days=7), - created_at=datetime.now() + created_at=datetime.now(), ) - + session.add(refresh_token_record) await session.commit() - + # Logger succès await log_login_attempt(session, data.email.lower(), ip, user_agent, True) - + logger.info(f"✅ Connexion réussie: {user.email}") - + return TokenResponse( access_token=access_token, refresh_token=refresh_token_jwt, - expires_in=1800 # 30 minutes + expires_in=86400, # 30 minutes ) @router.post("/refresh", response_model=TokenResponse) async def refresh_access_token( - data: RefreshTokenRequest, - session: AsyncSession = Depends(get_session) + data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) ): """ 🔄 Renouvellement du access_token via refresh_token @@ -448,61 +436,55 @@ async def refresh_access_token( payload = decode_token(data.refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token invalide" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide" ) - + user_id = payload.get("sub") token_hash = hash_token(data.refresh_token) - + # Vérifier en DB result = await session.execute( select(RefreshToken).where( RefreshToken.user_id == user_id, RefreshToken.token_hash == token_hash, - RefreshToken.is_revoked == False + RefreshToken.is_revoked == False, ) ) token_record = result.scalar_one_or_none() - + if not token_record: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token révoqué ou introuvable" + detail="Refresh token révoqué ou introuvable", ) - + # Vérifier expiration if token_record.expires_at < datetime.now(): raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token expiré" + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" ) - + # Charger utilisateur - result = await session.execute( - select(User).where(User.id == user_id) - ) + result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() - + if not user or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable ou désactivé" + detail="Utilisateur introuvable ou désactivé", ) - + # Générer nouveau access token - new_access_token = create_access_token({ - "sub": user.id, - "email": user.email, - "role": user.role - }) - + new_access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) + logger.info(f"🔄 Token rafraîchi: {user.email}") - + return TokenResponse( access_token=new_access_token, refresh_token=data.refresh_token, # Refresh token reste le même - expires_in=1800 + expires_in=86400, ) @@ -510,79 +492,71 @@ async def refresh_access_token( async def forgot_password( data: ForgotPasswordRequest, request: Request, - session: AsyncSession = Depends(get_session) + session: AsyncSession = Depends(get_session), ): """ 🔑 Demande de réinitialisation de mot de passe """ - result = await session.execute( - select(User).where(User.email == data.email.lower()) - ) + result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - + # Ne pas révéler si l'utilisateur existe if not user: return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } - + # Générer token de reset reset_token = generate_reset_token() user.reset_token = reset_token user.reset_token_expires = datetime.now() + timedelta(hours=1) await session.commit() - + # Envoyer email - frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/') - AuthEmailService.send_password_reset_email( - user.email, - reset_token, - frontend_url + frontend_url = ( + settings.frontend_url + if hasattr(settings, "frontend_url") + else str(request.base_url).rstrip("/") ) - + AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) + logger.info(f"📧 Reset password demandé: {user.email}") - + return { "success": True, - "message": "Si cet email existe, un lien de réinitialisation a été envoyé." + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } @router.post("/reset-password") async def reset_password( - data: ResetPasswordRequest, - session: AsyncSession = Depends(get_session) + data: ResetPasswordRequest, session: AsyncSession = Depends(get_session) ): """ 🔐 Réinitialisation du mot de passe avec token """ - result = await session.execute( - select(User).where(User.reset_token == data.token) - ) + result = await session.execute(select(User).where(User.reset_token == data.token)) user = result.scalar_one_or_none() - + if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token de réinitialisation invalide" + detail="Token de réinitialisation invalide", ) - + # Vérifier expiration if user.reset_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Token expiré. Demandez un nouveau lien de réinitialisation." + detail="Token expiré. Demandez un nouveau lien de réinitialisation.", ) - + # Valider nouveau mot de passe is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=error_msg - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + # Mettre à jour user.hashed_password = hash_password(data.new_password) user.reset_token = None @@ -590,15 +564,15 @@ async def reset_password( user.failed_login_attempts = 0 user.locked_until = None await session.commit() - + # Envoyer notification AuthEmailService.send_password_changed_notification(user.email) - + logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") - + return { "success": True, - "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter." + "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.", } @@ -606,32 +580,28 @@ async def reset_password( async def logout( data: RefreshTokenRequest, session: AsyncSession = Depends(get_session), - user: User = Depends(get_current_user) + user: User = Depends(get_current_user), ): """ 🚪 Déconnexion (révocation du refresh token) """ token_hash = hash_token(data.refresh_token) - + result = await session.execute( select(RefreshToken).where( - RefreshToken.user_id == user.id, - RefreshToken.token_hash == token_hash + RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash ) ) token_record = result.scalar_one_or_none() - + if token_record: token_record.is_revoked = True token_record.revoked_at = datetime.now() await session.commit() - + logger.info(f"👋 Déconnexion: {user.email}") - - return { - "success": True, - "message": "Déconnexion réussie" - } + + return {"success": True, "message": "Déconnexion réussie"} @router.get("/me") @@ -647,5 +617,5 @@ async def get_current_user_info(user: User = Depends(get_current_user)): "role": user.role, "is_verified": user.is_verified, "created_at": user.created_at.isoformat(), - "last_login": user.last_login.isoformat() if user.last_login else None - } \ No newline at end of file + "last_login": user.last_login.isoformat() if user.last_login else None, + } diff --git a/sage_client.py b/sage_client.py index 9ea5265..64208ae 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,5 +1,5 @@ import requests -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from config import settings import logging @@ -63,9 +63,6 @@ class SageGatewayClient: raise time.sleep(2**attempt) - # ===================================================== - # CLIENTS - # ===================================================== def lister_clients(self, filtre: str = "") -> List[Dict]: """Liste tous les clients avec filtre optionnel""" return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) @@ -74,9 +71,6 @@ class SageGatewayClient: """Lecture d'un client par code""" return self._post("/sage/clients/get", {"code": code}).get("data") - # ===================================================== - # ARTICLES - # ===================================================== def lister_articles(self, filtre: str = "") -> List[Dict]: """Liste tous les articles avec filtre optionnel""" return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) @@ -85,9 +79,6 @@ class SageGatewayClient: """Lecture d'un article par référence""" return self._post("/sage/articles/get", {"code": ref}).get("data") - # ===================================================== - # DEVIS (US-A1) - # ===================================================== def creer_devis(self, devis_data: Dict) -> Dict: """Création d'un devis""" return self._post("/sage/devis/create", devis_data).get("data", {}) @@ -102,18 +93,12 @@ class SageGatewayClient: statut: Optional[int] = None, inclure_lignes: bool = True, ) -> List[Dict]: - """ - ✅ Liste tous les devis avec filtres - """ payload = {"limit": limit, "inclure_lignes": inclure_lignes} if statut is not None: payload["statut"] = statut return self._post("/sage/devis/list", payload).get("data", []) def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: - """ - ✅ CORRECTION: Utilise query params au lieu du body - """ try: r = requests.post( f"{self.url}/sage/devis/statut", @@ -130,9 +115,6 @@ class SageGatewayClient: logger.error(f"❌ Erreur changement statut: {e}") raise - # ===================================================== - # DOCUMENTS GÉNÉRIQUES - # ===================================================== def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: """Lecture d'un document générique""" return self._post( @@ -142,9 +124,6 @@ class SageGatewayClient: def transformer_document( self, numero_source: str, type_source: int, type_cible: int ) -> Dict: - """ - ✅ CORRECTION: Utilise query params pour la transformation - """ try: r = requests.post( f"{self.url}/sage/documents/transform", @@ -177,30 +156,17 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # COMMANDES (US-A2) - # ===================================================== def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - Utilise l'endpoint /sage/commandes/list qui filtre déjà sur type 10 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/commandes/list", payload).get("data", []) - # ===================================================== - # FACTURES (US-A7) - # ===================================================== def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: - """ - ✅ Liste toutes les factures - Utilise l'endpoint /sage/factures/list qui filtre déjà sur type 60 - """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut @@ -213,24 +179,15 @@ class SageGatewayClient: ) return resp.get("success", False) - # ===================================================== - # CONTACTS (US-A6) - # ===================================================== def lire_contact_client(self, code_client: str) -> Optional[Dict]: """Lecture du contact principal d'un client""" return self._post("/sage/contact/read", {"code": code_client}).get("data") - # ===================================================== - # REMISES (US-A5) - # ===================================================== def lire_remise_max_client(self, code_client: str) -> float: """Récupère la remise max autorisée pour un client""" result = self._post("/sage/client/remise-max", {"code": code_client}) return result.get("data", {}).get("remise_max", 10.0) - # ===================================================== - # GÉNÉRATION PDF (pour email_queue) - # ===================================================== def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """Génère le PDF d'un document via la gateway Windows""" try: @@ -256,6 +213,57 @@ class SageGatewayClient: logger.error(f"Erreur génération PDF: {e}") raise + def lister_prospects(self, filtre: str = "") -> List[Dict]: + """Liste tous les prospects avec filtre optionnel""" + return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) + + def lire_prospect(self, code: str) -> Optional[Dict]: + """Lecture d'un prospect par code""" + return self._post("/sage/prospects/get", {"code": code}).get("data") + + def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: + """Liste tous les fournisseurs avec filtre optionnel""" + return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) + + def lire_fournisseur(self, code: str) -> Optional[Dict]: + """Lecture d'un fournisseur par code""" + return self._post("/sage/fournisseurs/get", {"code": code}).get("data") + + def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: + return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) + + def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: + return self._post( + "/sage/fournisseurs/update", + {"code": code, "fournisseur_data": fournisseur_data}, + ).get("data", {}) + + def lister_avoirs( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste tous les avoirs""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/avoirs/list", payload).get("data", []) + + def lire_avoir(self, numero: str) -> Optional[Dict]: + """Lecture d'un avoir avec ses lignes""" + return self._post("/sage/avoirs/get", {"code": numero}).get("data") + + def lister_livraisons( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + """Liste tous les bons de livraison""" + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/livraisons/list", payload).get("data", []) + + def lire_livraison(self, numero: str) -> Optional[Dict]: + """Lecture d'une livraison avec ses lignes""" + return self._post("/sage/livraisons/get", {"code": numero}).get("data") + # ===================================================== # CACHE (ADMIN) # ===================================================== @@ -278,6 +286,202 @@ class SageGatewayClient: except: return {"status": "down"} + def creer_client(self, client_data: Dict) -> Dict: + return self._post("/sage/clients/create", client_data).get("data", {}) -# Instance globale + def modifier_client(self, code: str, client_data: Dict) -> Dict: + return self._post( + "/sage/clients/update", {"code": code, "client_data": client_data} + ).get("data", {}) + + def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: + return self._post( + "/sage/devis/update", {"numero": numero, "devis_data": devis_data} + ).get("data", {}) + + def creer_commande(self, commande_data: Dict) -> Dict: + return self._post("/sage/commandes/create", commande_data).get("data", {}) + + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: + return self._post( + "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} + ).get("data", {}) + + def creer_livraison(self, livraison_data: Dict) -> Dict: + return self._post("/sage/livraisons/create", livraison_data).get("data", {}) + + def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: + return self._post( + "/sage/livraisons/update", + {"numero": numero, "livraison_data": livraison_data}, + ).get("data", {}) + + def creer_avoir(self, avoir_data: Dict) -> Dict: + return self._post("/sage/avoirs/create", avoir_data).get("data", {}) + + def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: + return self._post( + "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} + ).get("data", {}) + + def creer_facture(self, facture_data: Dict) -> Dict: + return self._post("/sage/factures/create", facture_data).get("data", {}) + + def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: + return self._post( + "/sage/factures/update", {"numero": numero, "facture_data": facture_data} + ).get("data", {}) + + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: + try: + logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") + + # Appel HTTP vers la gateway Windows + r = requests.post( + f"{self.url}/sage/documents/generate-pdf", + json={"doc_id": doc_id, "type_doc": type_doc}, + headers=self.headers, + timeout=60, # Timeout élevé pour génération PDF + ) + + r.raise_for_status() + + import base64 + + response_data = r.json() + + # Vérifier que la réponse contient bien le PDF + if not response_data.get("success"): + error_msg = response_data.get("error", "Erreur inconnue") + raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") + + pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") + + if not pdf_base64: + raise ValueError( + f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" + ) + + # Décoder le base64 + pdf_bytes = base64.b64decode(pdf_base64) + + logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") + + return pdf_bytes + + except requests.exceptions.Timeout: + logger.error(f"⏱️ Timeout génération PDF pour {doc_id}") + raise RuntimeError( + f"Timeout lors de la génération du PDF (>60s). " + f"Le document {doc_id} est peut-être trop volumineux." + ) + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur HTTP génération PDF: {e}") + raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") + + except Exception as e: + logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) + raise + + def creer_article(self, article_data: Dict) -> Dict: + return self._post("/sage/articles/create", article_data).get("data", {}) + + def modifier_article(self, reference: str, article_data: Dict) -> Dict: + return self._post( + "/sage/articles/update", + {"reference": reference, "article_data": article_data}, + ).get("data", {}) + + def lister_familles(self, filtre: str = "") -> List[Dict]: + return self._get("/sage/familles", params={"filtre": filtre}).get("data", []) + + def lire_famille(self, code: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/familles/{code}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture famille {code}: {e}") + return None + + def creer_famille(self, famille_data: Dict) -> Dict: + return self._post("/sage/familles/create", famille_data).get("data", {}) + + def get_stats_familles(self) -> Dict: + return self._get("/sage/familles/stats").get("data", {}) + + + def creer_entree_stock(self, entree_data: Dict) -> Dict: + return self._post("/sage/stock/entree", entree_data).get("data", {}) + + + def creer_sortie_stock(self, sortie_data: Dict) -> Dict: + return self._post("/sage/stock/sortie", sortie_data).get("data", {}) + + + def lire_mouvement_stock(self, numero: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/stock/mouvement/{numero}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture mouvement {numero}: {e}") + return None + + + def lister_modeles_disponibles(self) -> Dict: + """Liste les modèles Crystal Reports disponibles""" + try: + r = requests.get( + f"{self.url}/sage/modeles/list", + headers=self.headers, + timeout=30 + ) + r.raise_for_status() + return r.json().get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur listage modèles: {e}") + raise + + + def generer_pdf_document( + self, + numero: str, + type_doc: int, + modele: str = None, + base64_encode: bool = True + ) -> Union[bytes, str, Dict]: + """ + Génère un PDF d'un document Sage + + Returns: + Dict: Avec pdf_base64 si base64_encode=True + bytes: Contenu PDF brut si base64_encode=False + """ + try: + params = { + "type_doc": type_doc, + "base64_encode": base64_encode + } + + if modele: + params["modele"] = modele + + r = requests.get( + f"{self.url}/sage/documents/{numero}/pdf", + params=params, + headers=self.headers, + timeout=60 # PDF peut prendre du temps + ) + r.raise_for_status() + + if base64_encode: + return r.json().get("data", {}) + else: + return r.content + + except requests.exceptions.RequestException as e: + logger.error(f"❌ Erreur génération PDF: {e}") + raise + + sage_client = SageGatewayClient() diff --git a/security/auth.py b/security/auth.py index 9c5009d..7fc182c 100644 --- a/security/auth.py +++ b/security/auth.py @@ -45,24 +45,20 @@ def hash_token(token: str) -> str: def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: """ Crée un JWT access token - + Args: data: Payload (doit contenir 'sub' = user_id) expires_delta: Durée de validité personnalisée """ to_encode = data.copy() - + if expires_delta: expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - - to_encode.update({ - "exp": expire, - "iat": datetime.utcnow(), - "type": "access" - }) - + + to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -70,20 +66,20 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) - def create_refresh_token(user_id: str) -> str: """ Crée un refresh token (JWT long terme) - + Returns: Token JWT non hashé (à hasher avant stockage DB) """ expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) - + to_encode = { "sub": user_id, "exp": expire, "iat": datetime.utcnow(), "type": "refresh", - "jti": secrets.token_urlsafe(16) # Unique ID + "jti": secrets.token_urlsafe(16), # Unique ID } - + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt @@ -91,7 +87,7 @@ def create_refresh_token(user_id: str) -> str: def decode_token(token: str) -> Optional[Dict]: """ Décode et valide un JWT - + Returns: Payload si valide, None sinon """ @@ -108,24 +104,24 @@ def decode_token(token: str) -> Optional[Dict]: def validate_password_strength(password: str) -> tuple[bool, str]: """ Valide la robustesse d'un mot de passe - + Returns: (is_valid, error_message) """ if len(password) < 8: return False, "Le mot de passe doit contenir au moins 8 caractères" - + if not any(c.isupper() for c in password): return False, "Le mot de passe doit contenir au moins une majuscule" - + if not any(c.islower() for c in password): return False, "Le mot de passe doit contenir au moins une minuscule" - + if not any(c.isdigit() for c in password): return False, "Le mot de passe doit contenir au moins un chiffre" - + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" if not any(c in special_chars for c in password): return False, "Le mot de passe doit contenir au moins un caractère spécial" - - return True, "" \ No newline at end of file + + return True, "" diff --git a/services/email_service.py b/services/email_service.py index 44152df..7bb7661 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -9,46 +9,48 @@ logger = logging.getLogger(__name__) class AuthEmailService: """Service d'envoi d'emails pour l'authentification""" - + @staticmethod def _send_email(to: str, subject: str, html_body: str) -> bool: """Envoi SMTP générique""" try: msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = to - msg['Subject'] = subject - - msg.attach(MIMEText(html_body, 'html')) - - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: + msg["From"] = settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + + msg.attach(MIMEText(html_body, "html")) + + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: if settings.smtp_use_tls: server.starttls() - + if settings.smtp_user and settings.smtp_password: server.login(settings.smtp_user, settings.smtp_password) - + server.send_message(msg) - + logger.info(f"✅ Email envoyé: {subject} → {to}") return True - + except Exception as e: logger.error(f"❌ Erreur envoi email: {e}") return False - + @staticmethod def send_verification_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de vérification avec lien de confirmation - + Args: email: Email du destinataire token: Token de vérification base_url: URL de base de l'API (ex: https://api.votredomaine.com) """ verification_link = f"{base_url}/auth/verify-email?token={token}" - + html_body = f""" @@ -103,25 +105,23 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Vérifiez votre adresse email - Sage Dataven", - html_body + email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body ) - + @staticmethod def send_password_reset_email(email: str, token: str, base_url: str) -> bool: """ Envoie l'email de réinitialisation de mot de passe - + Args: email: Email du destinataire token: Token de reset base_url: URL de base du frontend """ reset_link = f"{base_url}/reset?token={token}" - + html_body = f""" @@ -176,13 +176,11 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "🔐 Réinitialisation de votre mot de passe - Sage Dataven", - html_body + email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body ) - + @staticmethod def send_password_changed_notification(email: str) -> bool: """Notification après changement de mot de passe réussi""" @@ -218,9 +216,7 @@ class AuthEmailService: """ - + return AuthEmailService._send_email( - email, - "✅ Votre mot de passe a été modifié - Sage Dataven", - html_body - ) \ No newline at end of file + email, "✅ Votre mot de passe a été modifié - Sage Dataven", html_body + )