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 :
+
+
+
+
+
+
+
+
+ |
+
+ ⏰ 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 :
+
+
+
+
+
+
+ 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: