4593 lines
160 KiB
Python
4593 lines
160 KiB
Python
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, validator, field_validator
|
|
from typing import List, Optional, Dict
|
|
from datetime import date, datetime
|
|
from enum import Enum
|
|
import uvicorn
|
|
from contextlib import asynccontextmanager
|
|
import uuid
|
|
import csv
|
|
import io
|
|
import logging
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
from routes.auth import router as auth_router
|
|
from core.dependencies import get_current_user, require_role
|
|
|
|
|
|
# Configuration logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
handlers=[logging.FileHandler("sage_api.log"), logging.StreamHandler()],
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Imports locaux
|
|
from config import settings
|
|
from database import (
|
|
init_db,
|
|
async_session_factory,
|
|
get_session,
|
|
EmailLog,
|
|
StatutEmail as StatutEmailEnum,
|
|
WorkflowLog,
|
|
SignatureLog,
|
|
StatutSignature as StatutSignatureEnum,
|
|
)
|
|
from email_queue import email_queue
|
|
from sage_client import sage_client
|
|
|
|
|
|
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
|
|
# =====================================================
|
|
class TypeDocument(int, Enum):
|
|
DEVIS = settings.SAGE_TYPE_DEVIS
|
|
BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE
|
|
PREPARATION = settings.SAGE_TYPE_PREPARATION
|
|
BON_LIVRAISON = settings.SAGE_TYPE_BON_LIVRAISON
|
|
BON_RETOUR = settings.SAGE_TYPE_BON_RETOUR
|
|
BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR
|
|
FACTURE = settings.SAGE_TYPE_FACTURE
|
|
|
|
|
|
class TypeDocumentSQL(int, Enum):
|
|
DEVIS = settings.SAGE_TYPE_DEVIS
|
|
BON_COMMANDE = 1
|
|
PREPARATION = 2
|
|
BON_LIVRAISON = 3
|
|
BON_RETOUR = 4
|
|
BON_AVOIR = 5
|
|
FACTURE = 6
|
|
|
|
|
|
class StatutSignature(str, Enum):
|
|
EN_ATTENTE = "EN_ATTENTE"
|
|
ENVOYE = "ENVOYE"
|
|
SIGNE = "SIGNE"
|
|
REFUSE = "REFUSE"
|
|
EXPIRE = "EXPIRE"
|
|
|
|
|
|
class StatutEmail(str, Enum):
|
|
EN_ATTENTE = "EN_ATTENTE"
|
|
EN_COURS = "EN_COURS"
|
|
ENVOYE = "ENVOYE"
|
|
OUVERT = "OUVERT"
|
|
ERREUR = "ERREUR"
|
|
BOUNCE = "BOUNCE"
|
|
|
|
|
|
# =====================================================
|
|
# MODÈLES PYDANTIC
|
|
# =====================================================
|
|
class ClientResponse(BaseModel):
|
|
"""Modèle de réponse client simplifié (pour listes)"""
|
|
|
|
numero: Optional[str] = None
|
|
intitule: Optional[str] = None
|
|
adresse: Optional[str] = None
|
|
code_postal: Optional[str] = None
|
|
ville: Optional[str] = None
|
|
email: Optional[str] = None
|
|
telephone: Optional[str] = None # Téléphone principal (fixe ou mobile)
|
|
|
|
|
|
class 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):
|
|
"""
|
|
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):
|
|
article_code: str
|
|
quantite: float
|
|
remise_pourcentage: Optional[float] = 0.0
|
|
|
|
@field_validator("article_code", mode="before")
|
|
def strip_insecables(cls, v):
|
|
return v.replace("\xa0", "").strip()
|
|
|
|
|
|
class DevisRequest(BaseModel):
|
|
client_id: str
|
|
date_devis: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
reference: Optional[str] = None
|
|
lignes: List[LigneDevis]
|
|
|
|
|
|
class DevisResponse(BaseModel):
|
|
id: str
|
|
client_id: str
|
|
date_devis: str
|
|
montant_total_ht: float
|
|
montant_total_ttc: float
|
|
nb_lignes: int
|
|
|
|
|
|
class SignatureRequest(BaseModel):
|
|
doc_id: str
|
|
type_doc: TypeDocument
|
|
email_signataire: EmailStr
|
|
nom_signataire: str
|
|
|
|
|
|
class EmailEnvoiRequest(BaseModel):
|
|
destinataire: EmailStr
|
|
cc: Optional[List[EmailStr]] = []
|
|
cci: Optional[List[EmailStr]] = []
|
|
sujet: str
|
|
corps_html: str
|
|
document_ids: Optional[List[str]] = None
|
|
type_document: Optional[TypeDocument] = None
|
|
|
|
|
|
class RelanceDevisRequest(BaseModel):
|
|
doc_id: str
|
|
message_personnalise: Optional[str] = None
|
|
|
|
|
|
class BaremeRemiseResponse(BaseModel):
|
|
client_id: str
|
|
remise_max_autorisee: float
|
|
remise_demandee: float
|
|
autorisee: bool
|
|
message: str
|
|
|
|
|
|
class 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
|
|
|
|
# =====================================================
|
|
# MODÈLES PYDANTIC POUR USERS
|
|
# =====================================================
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
"""Modèle de réponse pour un utilisateur"""
|
|
|
|
id: str
|
|
email: str
|
|
nom: str
|
|
prenom: str
|
|
role: str
|
|
is_verified: bool
|
|
is_active: bool
|
|
created_at: str
|
|
last_login: Optional[str] = None
|
|
failed_login_attempts: int = 0
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class FournisseurCreateAPIRequest(BaseModel):
|
|
intitule: str = Field(
|
|
..., min_length=1, max_length=69, description="Raison sociale du fournisseur"
|
|
)
|
|
compte_collectif: str = Field(
|
|
"401000", description="Compte comptable fournisseur (ex: 401000)"
|
|
)
|
|
num: Optional[str] = Field(
|
|
None, max_length=17, description="Code fournisseur souhaité (optionnel)"
|
|
)
|
|
adresse: Optional[str] = Field(None, max_length=35)
|
|
code_postal: Optional[str] = Field(None, max_length=9)
|
|
ville: Optional[str] = Field(None, max_length=35)
|
|
pays: Optional[str] = Field(None, max_length=35)
|
|
email: Optional[EmailStr] = None
|
|
telephone: Optional[str] = Field(None, max_length=21)
|
|
siret: Optional[str] = Field(None, max_length=14)
|
|
tva_intra: Optional[str] = Field(None, max_length=25)
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"intitule": "ACME SUPPLIES SARL",
|
|
"compte_collectif": "401000",
|
|
"num": "FOUR001",
|
|
"adresse": "15 Rue du Commerce",
|
|
"code_postal": "75001",
|
|
"ville": "Paris",
|
|
"pays": "France",
|
|
"email": "contact@acmesupplies.fr",
|
|
"telephone": "0145678901",
|
|
"siret": "12345678901234",
|
|
"tva_intra": "FR12345678901",
|
|
}
|
|
}
|
|
|
|
|
|
class FournisseurUpdateRequest(BaseModel):
|
|
"""Modèle pour modification d'un fournisseur existant"""
|
|
|
|
intitule: Optional[str] = Field(None, min_length=1, max_length=69)
|
|
adresse: Optional[str] = Field(None, max_length=35)
|
|
code_postal: Optional[str] = Field(None, max_length=9)
|
|
ville: Optional[str] = Field(None, max_length=35)
|
|
pays: Optional[str] = Field(None, max_length=35)
|
|
email: Optional[EmailStr] = None
|
|
telephone: Optional[str] = Field(None, max_length=21)
|
|
siret: Optional[str] = Field(None, max_length=14)
|
|
tva_intra: Optional[str] = Field(None, max_length=25)
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"intitule": "ACME SUPPLIES MODIFIÉ",
|
|
"email": "nouveau@acme.fr",
|
|
"telephone": "0198765432",
|
|
}
|
|
}
|
|
|
|
|
|
class DevisUpdateRequest(BaseModel):
|
|
"""Modèle pour modification d'un devis existant"""
|
|
|
|
date_devis: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
reference: Optional[str] = None
|
|
lignes: Optional[List[LigneDevis]] = None
|
|
statut: Optional[int] = Field(None, ge=0, le=6)
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"date_devis": "2024-01-15",
|
|
"date_livraison": "2024-01-15",
|
|
"reference": "DEV-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 5.0,
|
|
"prix_unitaire_ht": 100.0,
|
|
"remise_pourcentage": 10.0,
|
|
}
|
|
],
|
|
"statut": 2,
|
|
}
|
|
}
|
|
|
|
|
|
class LigneCommande(BaseModel):
|
|
"""Ligne de commande"""
|
|
|
|
article_code: str
|
|
quantite: float
|
|
remise_pourcentage: Optional[float] = 0.0
|
|
|
|
@field_validator("article_code", mode="before")
|
|
def strip_insecables(cls, v):
|
|
return v.replace("\xa0", "").strip()
|
|
|
|
|
|
class CommandeCreateRequest(BaseModel):
|
|
"""Création d'une commande"""
|
|
|
|
client_id: str
|
|
date_commande: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
lignes: List[LigneCommande]
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"client_id": "CLI000001",
|
|
"date_commande": "2024-01-15",
|
|
"reference": "CMD-EXT-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 10.0,
|
|
"prix_unitaire_ht": 50.0,
|
|
"remise_pourcentage": 5.0,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
class CommandeUpdateRequest(BaseModel):
|
|
"""Modification d'une commande existante"""
|
|
|
|
date_commande: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
lignes: Optional[List[LigneCommande]] = None
|
|
statut: Optional[int] = Field(None, ge=0, le=6)
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"date_commande": "2024-01-15",
|
|
"date_livraison": "2024-01-15",
|
|
"reference": "CMD-EXT-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 15.0,
|
|
"prix_unitaire_ht": 45.0,
|
|
}
|
|
],
|
|
"statut": 2,
|
|
}
|
|
}
|
|
|
|
|
|
class LigneLivraison(BaseModel):
|
|
"""Ligne de livraison"""
|
|
|
|
article_code: str
|
|
quantite: float
|
|
remise_pourcentage: Optional[float] = 0.0
|
|
|
|
@field_validator("article_code", mode="before")
|
|
def strip_insecables(cls, v):
|
|
return v.replace("\xa0", "").strip()
|
|
|
|
|
|
class LivraisonCreateRequest(BaseModel):
|
|
"""Création d'une livraison"""
|
|
|
|
client_id: str
|
|
date_livraison: Optional[date] = None
|
|
date_livraison_prevue: Optional[date] = None
|
|
lignes: List[LigneLivraison]
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"client_id": "CLI000001",
|
|
"date_livraison": "2024-01-15",
|
|
"reference": "BL-EXT-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 10.0,
|
|
"prix_unitaire_ht": 50.0,
|
|
"remise_pourcentage": 5.0,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
class LivraisonUpdateRequest(BaseModel):
|
|
"""Modification d'une livraison existante"""
|
|
|
|
date_livraison: Optional[date] = None
|
|
date_livraison_prevue: Optional[date] = None
|
|
lignes: Optional[List[LigneLivraison]] = None
|
|
statut: Optional[int] = Field(None, ge=0, le=6)
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"date_livraison": "2024-01-15",
|
|
"date_livraison_prevue": "2024-01-15",
|
|
"reference": "BL-EXT-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 15.0,
|
|
"prix_unitaire_ht": 45.0,
|
|
}
|
|
],
|
|
"statut": 2,
|
|
}
|
|
}
|
|
|
|
|
|
class LigneAvoir(BaseModel):
|
|
"""Ligne d'avoir"""
|
|
|
|
article_code: str
|
|
quantite: float
|
|
remise_pourcentage: Optional[float] = 0.0
|
|
|
|
@field_validator("article_code", mode="before")
|
|
def strip_insecables(cls, v):
|
|
return v.replace("\xa0", "").strip()
|
|
|
|
|
|
class AvoirCreateRequest(BaseModel):
|
|
"""Création d'un avoir"""
|
|
|
|
client_id: str
|
|
date_avoir: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
lignes: List[LigneAvoir]
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"client_id": "CLI000001",
|
|
"date_avoir": "2024-01-15",
|
|
"reference": "AV-EXT-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 5.0,
|
|
"prix_unitaire_ht": 50.0,
|
|
"remise_pourcentage": 0.0,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
class AvoirUpdateRequest(BaseModel):
|
|
"""Modification d'un avoir existant"""
|
|
|
|
date_avoir: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
lignes: Optional[List[LigneAvoir]] = None
|
|
statut: Optional[int] = Field(None, ge=0, le=6)
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"date_avoir": "2024-01-15",
|
|
"date_livraison": "2024-01-15",
|
|
"reference": "AV-EXT-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 10.0,
|
|
"prix_unitaire_ht": 45.0,
|
|
}
|
|
],
|
|
"statut": 2,
|
|
}
|
|
}
|
|
|
|
|
|
class LigneFacture(BaseModel):
|
|
"""Ligne de facture"""
|
|
|
|
article_code: str
|
|
quantite: float
|
|
remise_pourcentage: Optional[float] = 0.0
|
|
|
|
@field_validator("article_code", mode="before")
|
|
def strip_insecables(cls, v):
|
|
return v.replace("\xa0", "").strip()
|
|
|
|
|
|
class FactureCreateRequest(BaseModel):
|
|
"""Création d'une facture"""
|
|
|
|
client_id: str
|
|
date_facture: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
lignes: List[LigneFacture]
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"client_id": "CLI000001",
|
|
"date_facture": "2024-01-15",
|
|
"reference": "FA-EXT-001",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 10.0,
|
|
"prix_unitaire_ht": 50.0,
|
|
"remise_pourcentage": 5.0,
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
class FactureUpdateRequest(BaseModel):
|
|
"""Modification d'une facture existante"""
|
|
|
|
date_facture: Optional[date] = None
|
|
date_livraison: Optional[date] = None
|
|
lignes: Optional[List[LigneFacture]] = None
|
|
statut: Optional[int] = Field(None, ge=0, le=6)
|
|
reference: Optional[str] = None
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"date_facture": "2024-01-15",
|
|
"date_livraison": "2024-01-15",
|
|
"lignes": [
|
|
{
|
|
"article_code": "ART001",
|
|
"quantite": 15.0,
|
|
"prix_unitaire_ht": 45.0,
|
|
}
|
|
],
|
|
"statut": 2,
|
|
}
|
|
}
|
|
|
|
|
|
class ArticleCreateRequest(BaseModel):
|
|
"""Schéma pour création d'article"""
|
|
|
|
reference: str = Field(..., max_length=18, description="Référence article")
|
|
designation: str = Field(..., max_length=69, description="Désignation")
|
|
famille: Optional[str] = Field(None, max_length=18, description="Code famille")
|
|
prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT")
|
|
prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT")
|
|
stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial")
|
|
stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum")
|
|
code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres")
|
|
unite_vente: Optional[str] = Field("UN", max_length=4, description="Unité")
|
|
tva_code: Optional[str] = Field(None, max_length=5, description="Code TVA")
|
|
description: Optional[str] = Field(None, description="Description")
|
|
|
|
|
|
class ArticleUpdateRequest(BaseModel):
|
|
"""Schéma pour modification d'article"""
|
|
|
|
designation: Optional[str] = Field(None, max_length=69)
|
|
prix_vente: Optional[float] = Field(None, ge=0)
|
|
prix_achat: Optional[float] = Field(None, ge=0)
|
|
stock_reel: Optional[float] = Field(
|
|
None, ge=0, description="⚠️ Critique pour erreur 2881"
|
|
)
|
|
stock_mini: Optional[float] = Field(None, ge=0)
|
|
code_ean: Optional[str] = Field(None, max_length=13)
|
|
description: Optional[str] = Field(None)
|
|
|
|
|
|
class FamilleCreateRequest(BaseModel):
|
|
"""Schéma pour création de famille d'articles"""
|
|
|
|
code: str = Field(..., max_length=18, description="Code famille (max 18 car)")
|
|
intitule: str = Field(..., max_length=69, description="Intitulé (max 69 car)")
|
|
type: int = Field(0, ge=0, le=1, description="0=Détail, 1=Total")
|
|
compte_achat: Optional[str] = Field(
|
|
None, max_length=13, description="Compte général achat (ex: 607000)"
|
|
)
|
|
compte_vente: Optional[str] = Field(
|
|
None, max_length=13, description="Compte général vente (ex: 707000)"
|
|
)
|
|
|
|
class Config:
|
|
json_schema_extra = {
|
|
"example": {
|
|
"code": "PRODLAIT",
|
|
"intitule": "Produits laitiers",
|
|
"type": 0,
|
|
"compte_achat": "607000",
|
|
"compte_vente": "707000",
|
|
}
|
|
}
|
|
|
|
|
|
class FamilleResponse(BaseModel):
|
|
"""Modèle 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": """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; background-color: #f4f7fa;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f7fa; padding: 40px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
|
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
<h1 style="color: #000; margin: 0; font-size: 24px; font-weight: 600;">
|
|
📝 Signature Électronique Requise
|
|
</h1>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Content -->
|
|
<tr>
|
|
<td style="padding: 40px 30px;">
|
|
<p style="color: #2d3748; font-size: 16px; line-height: 1.6; margin: 0 0 20px;">
|
|
Bonjour <strong>{{NOM_SIGNATAIRE}}</strong>,
|
|
</p>
|
|
|
|
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
|
|
Nous vous invitons à signer électroniquement le document suivant :
|
|
</p>
|
|
|
|
<!-- Document Info Box -->
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f7fafc; border-left: 4px solid #667eea; border-radius: 4px; margin-bottom: 30px;">
|
|
<tr>
|
|
<td style="padding: 20px;">
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Type de document</td>
|
|
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{TYPE_DOC}}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Numéro</td>
|
|
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{NUMERO}}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Date</td>
|
|
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{DATE}}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #718096; font-size: 13px; padding: 5px 0;">Montant TTC</td>
|
|
<td style="color: #2d3748; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{MONTANT_TTC}} €</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 30px;">
|
|
Cliquez sur le bouton ci-dessous pour accéder au document et apposer votre signature électronique sécurisée :
|
|
</p>
|
|
|
|
<!-- CTA Button -->
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td align="center" style="padding: 10px 0 30px;">
|
|
<a href="{{SIGNER_URL}}" style="display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #000; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);">
|
|
✍️ Signer le document
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- Info Box -->
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #fffaf0; border: 1px solid #fbd38d; border-radius: 4px; margin-bottom: 20px;">
|
|
<tr>
|
|
<td style="padding: 15px;">
|
|
<p style="color: #744210; font-size: 13px; line-height: 1.5; margin: 0;">
|
|
⏰ <strong>Important :</strong> Ce lien de signature est valable pendant <strong>30 jours</strong>.
|
|
Nous vous recommandons de signer ce document dès que possible.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="color: #718096; font-size: 13px; line-height: 1.5; margin: 0;">
|
|
<strong>🔒 Signature électronique sécurisée</strong><br>
|
|
Votre signature est protégée par notre partenaire de confiance <strong>Universign</strong>,
|
|
certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera
|
|
horodaté de manière infalsifiable.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="background-color: #f7fafc; padding: 25px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e2e8f0;">
|
|
<p style="color: #718096; font-size: 12px; line-height: 1.5; margin: 0 0 10px;">
|
|
Vous avez des questions ? Contactez-nous à <a href="mailto:{{CONTACT_EMAIL}}" style="color: #667eea; text-decoration: none;">{{CONTACT_EMAIL}}</a>
|
|
</p>
|
|
<p style="color: #a0aec0; font-size: 11px; line-height: 1.4; margin: 0;">
|
|
Cet email a été envoyé automatiquement par le système Sage 100c Dataven.<br>
|
|
Si vous avez reçu cet email par erreur, veuillez nous en informer.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
""",
|
|
"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": """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; background-color: #f4f7fa;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f7fa; padding: 40px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
|
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">
|
|
✅ Document Signé avec Succès
|
|
</h1>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Content -->
|
|
<tr>
|
|
<td style="padding: 40px 30px;">
|
|
<p style="color: #2d3748; font-size: 16px; line-height: 1.6; margin: 0 0 20px;">
|
|
Bonjour <strong>{{NOM_SIGNATAIRE}}</strong>,
|
|
</p>
|
|
|
|
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
|
|
Nous confirmons la signature électronique du document suivant :
|
|
</p>
|
|
|
|
<!-- Success Box -->
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f0fff4; border-left: 4px solid #48bb78; border-radius: 4px; margin-bottom: 30px;">
|
|
<tr>
|
|
<td style="padding: 20px;">
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td style="color: #2f855a; font-size: 13px; padding: 5px 0;">Document</td>
|
|
<td style="color: #22543d; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{TYPE_DOC}} {{NUMERO}}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #2f855a; font-size: 13px; padding: 5px 0;">Signé le</td>
|
|
<td style="color: #22543d; font-size: 15px; font-weight: 600; text-align: right; padding: 5px 0;">{{DATE_SIGNATURE}}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="color: #2f855a; font-size: 13px; padding: 5px 0;">ID Transaction</td>
|
|
<td style="color: #22543d; font-size: 13px; font-family: monospace; text-align: right; padding: 5px 0;">{{TRANSACTION_ID}}</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
|
|
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é.
|
|
</p>
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ebf8ff; border: 1px solid #90cdf4; border-radius: 4px; margin-bottom: 20px;">
|
|
<tr>
|
|
<td style="padding: 15px;">
|
|
<p style="color: #2c5282; font-size: 13px; line-height: 1.5; margin: 0;">
|
|
🔐 <strong>Signature certifiée :</strong> 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.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="color: #718096; font-size: 14px; line-height: 1.6; margin: 0;">
|
|
Merci pour votre confiance. Notre équipe reste à votre disposition pour toute question.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="background-color: #f7fafc; padding: 25px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e2e8f0;">
|
|
<p style="color: #718096; font-size: 12px; line-height: 1.5; margin: 0 0 10px;">
|
|
Contact : <a href="mailto:{{CONTACT_EMAIL}}" style="color: #48bb78; text-decoration: none;">{{CONTACT_EMAIL}}</a>
|
|
</p>
|
|
<p style="color: #a0aec0; font-size: 11px; line-height: 1.4; margin: 0;">
|
|
Sage 100c Dataven - Système de signature électronique sécurisée
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
""",
|
|
"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": """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
</head>
|
|
<body style="margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; background-color: #f4f7fa;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f7fa; padding: 40px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
|
|
|
|
<!-- Header -->
|
|
<tr>
|
|
<td style="background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
|
|
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">
|
|
⏰ Signature en Attente
|
|
</h1>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Content -->
|
|
<tr>
|
|
<td style="padding: 40px 30px;">
|
|
<p style="color: #2d3748; font-size: 16px; line-height: 1.6; margin: 0 0 20px;">
|
|
Bonjour <strong>{{NOM_SIGNATAIRE}}</strong>,
|
|
</p>
|
|
|
|
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 25px;">
|
|
Nous vous avons envoyé il y a <strong>{{NB_JOURS}}</strong> jours un document à signer électroniquement.
|
|
Nous constatons que celui-ci n'a pas encore été signé.
|
|
</p>
|
|
|
|
<!-- Warning Box -->
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #fffaf0; border-left: 4px solid #ed8936; border-radius: 4px; margin-bottom: 30px;">
|
|
<tr>
|
|
<td style="padding: 20px;">
|
|
<p style="color: #744210; font-size: 14px; line-height: 1.5; margin: 0 0 10px;">
|
|
<strong>Document en attente :</strong> {{TYPE_DOC}} {{NUMERO}}
|
|
</p>
|
|
<p style="color: #744210; font-size: 13px; line-height: 1.5; margin: 0;">
|
|
⏳ Le lien de signature expirera dans <strong>{{JOURS_RESTANTS}}</strong> jours
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="color: #4a5568; font-size: 15px; line-height: 1.6; margin: 0 0 30px;">
|
|
Pour éviter tout retard dans le traitement de votre dossier, nous vous invitons à signer ce document dès maintenant :
|
|
</p>
|
|
|
|
<!-- CTA Button -->
|
|
<table width="100%" cellpadding="0" cellspacing="0">
|
|
<tr>
|
|
<td align="center" style="padding: 10px 0 30px;">
|
|
<a href="{{SIGNER_URL}}" style="display: inline-block; background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); color: #ffffff; text-decoration: none; padding: 16px 40px; border-radius: 6px; font-size: 16px; font-weight: 600; box-shadow: 0 4px 12px rgba(237, 137, 54, 0.4);">
|
|
✍️ Signer maintenant
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<p style="color: #718096; font-size: 13px; line-height: 1.5; margin: 0;">
|
|
Si vous rencontrez des difficultés ou avez des questions, n'hésitez pas à nous contacter.
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="background-color: #f7fafc; padding: 25px 30px; border-radius: 0 0 8px 8px; border-top: 1px solid #e2e8f0;">
|
|
<p style="color: #718096; font-size: 12px; line-height: 1.5; margin: 0 0 10px;">
|
|
Contact : <a href="mailto:{{CONTACT_EMAIL}}" style="color: #ed8936; text-decoration: none;">{{CONTACT_EMAIL}}</a>
|
|
</p>
|
|
<p style="color: #a0aec0; font-size: 11px; line-height: 1.4; margin: 0;">
|
|
Sage 100c Dataven - Relance automatique
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
""",
|
|
"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,
|
|
session: AsyncSession,
|
|
) -> Dict:
|
|
import requests
|
|
|
|
try:
|
|
api_key = settings.universign_api_key
|
|
api_url = settings.universign_api_url
|
|
auth = (api_key, "")
|
|
|
|
# ========================================
|
|
# ÉTAPE 1 : Créer la transaction
|
|
# ========================================
|
|
logger.info(f"🔐 Création transaction Universign pour {email}")
|
|
|
|
response = requests.post(
|
|
f"{api_url}/transactions",
|
|
auth=auth,
|
|
json={
|
|
"name": f"{doc_data.get('type_label', 'Document')} {doc_id}",
|
|
"language": "fr",
|
|
"profile": "default", # ✅ Ajout du profil
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"❌ Erreur création transaction: {response.status_code} - {response.text}")
|
|
raise Exception(f"Erreur création transaction: {response.status_code}")
|
|
|
|
response.raise_for_status()
|
|
transaction_id = response.json().get("id")
|
|
|
|
logger.info(f"✅ Transaction créée: {transaction_id}")
|
|
|
|
# ========================================
|
|
# ÉTAPE 2 : Upload du fichier PDF
|
|
# ========================================
|
|
logger.info(f"📄 Upload PDF ({len(pdf_bytes)} octets)")
|
|
|
|
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
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"❌ Erreur upload fichier: {response.status_code} - {response.text}")
|
|
raise Exception(f"Erreur upload fichier: {response.status_code}")
|
|
|
|
response.raise_for_status()
|
|
file_id = response.json().get("id")
|
|
|
|
logger.info(f"✅ Fichier uploadé: {file_id}")
|
|
|
|
# ========================================
|
|
# ÉTAPE 3 : Créer le document dans la transaction
|
|
# ========================================
|
|
logger.info(f"📋 Ajout document à la transaction")
|
|
|
|
response = requests.post(
|
|
f"{api_url}/transactions/{transaction_id}/documents",
|
|
auth=auth,
|
|
json={"document": file_id}, # ✅ Utiliser 'file' au lieu de 'document'
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"❌ Erreur ajout document: {response.status_code} - {response.text}")
|
|
raise Exception(f"Erreur ajout document: {response.status_code}")
|
|
|
|
response.raise_for_status()
|
|
document_id = response.json().get("id")
|
|
|
|
logger.info(f"✅ Document ajouté: {document_id}")
|
|
|
|
# ========================================
|
|
# ÉTAPE 4 : Ajouter un champ de signature
|
|
# ========================================
|
|
logger.info(f"✍️ Ajout champ signature")
|
|
|
|
response = requests.post(
|
|
f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields",
|
|
auth=auth,
|
|
json={
|
|
"type": "signature",
|
|
"page": 1, # ✅ Préciser la page
|
|
# Position optionnelle - Universign peut la placer automatiquement
|
|
# "x": 100,
|
|
# "y": 600,
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"❌ Erreur ajout champ: {response.status_code} - {response.text}")
|
|
raise Exception(f"Erreur ajout champ signature: {response.status_code}")
|
|
|
|
response.raise_for_status()
|
|
field_id = response.json().get("id")
|
|
|
|
logger.info(f"✅ Champ signature créé: {field_id}")
|
|
|
|
# ========================================
|
|
# ÉTAPE 5 : Ajouter le signataire
|
|
# ========================================
|
|
logger.info(f"👤 Ajout signataire: {nom} ({email})")
|
|
|
|
response = requests.post(
|
|
f"{api_url}/transactions/{transaction_id}/signers",
|
|
auth=auth,
|
|
json={
|
|
"email": email,
|
|
"firstName": nom.split()[0] if ' ' in nom else nom, # ✅ Prénom
|
|
"lastName": nom.split()[-1] if ' ' in nom else "", # ✅ Nom
|
|
# ✅ Lier le signataire au champ de signature
|
|
"fields": [field_id],
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"❌ Erreur ajout signataire: {response.status_code} - {response.text}")
|
|
raise Exception(f"Erreur ajout signataire: {response.status_code}")
|
|
|
|
response.raise_for_status()
|
|
signer_id = response.json().get("id")
|
|
|
|
logger.info(f"✅ Signataire ajouté: {signer_id}")
|
|
|
|
# ========================================
|
|
# ÉTAPE 6 : Démarrer la transaction
|
|
# ========================================
|
|
logger.info(f"🚀 Démarrage de la transaction")
|
|
|
|
response = requests.post(
|
|
f"{api_url}/transactions/{transaction_id}/start",
|
|
auth=auth,
|
|
json={}, # ✅ Body vide mais présent
|
|
timeout=30
|
|
)
|
|
|
|
if response.status_code != 200:
|
|
logger.error(f"❌ Erreur démarrage transaction: {response.status_code}")
|
|
logger.error(f"Réponse complète: {response.text}")
|
|
raise Exception(f"Erreur démarrage transaction: {response.status_code} - {response.text}")
|
|
|
|
response.raise_for_status()
|
|
final_data = response.json()
|
|
|
|
# ========================================
|
|
# ÉTAPE 7 : Récupérer l'URL de signature
|
|
# ========================================
|
|
signer_url = ""
|
|
if final_data.get("signers"):
|
|
for signer in final_data["signers"]:
|
|
if signer.get("email") == email:
|
|
signer_url = signer.get("url", "")
|
|
break
|
|
|
|
if not signer_url:
|
|
logger.warning("⚠️ URL de signature non trouvée dans la réponse")
|
|
raise ValueError("URL de signature non retournée par Universign")
|
|
|
|
logger.info(f"✅ Signature Universign prête: {transaction_id}")
|
|
|
|
# ========================================
|
|
# ÉTAPE 8 : Créer l'email de notification
|
|
# ========================================
|
|
template = templates_signature_email["demande_signature"]
|
|
|
|
type_labels = {
|
|
0: "Devis",
|
|
10: "Commande",
|
|
30: "Bon de Livraison",
|
|
60: "Facture",
|
|
50: "Avoir",
|
|
}
|
|
|
|
variables = {
|
|
"NOM_SIGNATAIRE": nom,
|
|
"TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"),
|
|
"NUMERO": doc_id,
|
|
"DATE": doc_data.get("date", datetime.now().strftime("%d/%m/%Y")),
|
|
"MONTANT_TTC": f"{doc_data.get('montant_ttc', 0):.2f}",
|
|
"SIGNER_URL": signer_url,
|
|
"CONTACT_EMAIL": settings.smtp_from,
|
|
}
|
|
|
|
sujet = template["sujet"]
|
|
corps = template["corps_html"]
|
|
|
|
for var, valeur in variables.items():
|
|
sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur))
|
|
corps = corps.replace(f"{{{{{var}}}}}", str(valeur))
|
|
|
|
# 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 requests.exceptions.HTTPError as e:
|
|
logger.error(f"❌ Erreur HTTP Universign: {e}")
|
|
logger.error(f"Réponse: {e.response.text if e.response else 'N/A'}")
|
|
return {
|
|
"error": f"Erreur Universign: {e.response.status_code} - {e.response.text if e.response else str(e)}",
|
|
"statut": "ERREUR",
|
|
"email_sent": False,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur Universign+Email: {e}", exc_info=True)
|
|
return {"error": str(e), "statut": "ERREUR", "email_sent": False}
|
|
|
|
async def universign_statut(transaction_id: str) -> Dict:
|
|
"""Récupération statut signature"""
|
|
import requests
|
|
|
|
try:
|
|
response = requests.get(
|
|
f"{settings.universign_api_url}/transactions/{transaction_id}",
|
|
auth=(settings.universign_api_key, ""),
|
|
timeout=10,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
statut_map = {
|
|
"draft": "EN_ATTENTE",
|
|
"started": "EN_ATTENTE",
|
|
"completed": "SIGNE",
|
|
"refused": "REFUSE",
|
|
"expired": "EXPIRE",
|
|
"canceled": "REFUSE",
|
|
}
|
|
return {
|
|
"statut": statut_map.get(data.get("state"), "EN_ATTENTE"),
|
|
"date_signature": data.get("completed_at"),
|
|
}
|
|
else:
|
|
return {"statut": "ERREUR"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur statut Universign: {e}")
|
|
return {"statut": "ERREUR", "error": str(e)}
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
# Init base de données
|
|
await init_db()
|
|
logger.info("✅ Base de données initialisée")
|
|
|
|
email_queue.session_factory = async_session_factory
|
|
email_queue.sage_client = sage_client
|
|
|
|
logger.info("✅ sage_client injecté dans email_queue")
|
|
|
|
# Démarrer queue
|
|
email_queue.start(num_workers=settings.max_email_workers)
|
|
logger.info(f"✅ Email queue démarrée")
|
|
|
|
yield
|
|
|
|
# Cleanup
|
|
email_queue.stop()
|
|
logger.info("👋 Services arrêtés")
|
|
|
|
|
|
# =====================================================
|
|
# APPLICATION
|
|
# =====================================================
|
|
app = FastAPI(
|
|
title="API Sage 100c Dataven",
|
|
version="2.0.0",
|
|
description="API de gestion commerciale - VPS Linux",
|
|
lifespan=lifespan,
|
|
openapi_tags=TAGS_METADATA,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.cors_origins,
|
|
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
allow_headers=["*"],
|
|
allow_credentials=True,
|
|
)
|
|
|
|
|
|
app.include_router(auth_router)
|
|
|
|
|
|
@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"])
|
|
async def rechercher_clients(query: Optional[str] = Query(None)):
|
|
try:
|
|
clients = sage_client.lister_clients(filtre=query or "")
|
|
return [ClientDetails(**c) for c in clients]
|
|
except Exception as e:
|
|
logger.error(f"Erreur recherche clients: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/clients/{code}", 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)):
|
|
try:
|
|
articles = sage_client.lister_articles(filtre=query or "")
|
|
return [ArticleResponse(**a) for a in articles]
|
|
except Exception as e:
|
|
logger.error(f"Erreur recherche articles: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post(
|
|
"/articles",
|
|
response_model=ArticleResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
tags=["Articles"],
|
|
)
|
|
async def creer_article(article: ArticleCreateRequest):
|
|
try:
|
|
# 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):
|
|
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,
|
|
"date_livraison": (
|
|
devis.date_livraison.isoformat() if devis.date_livraison else None
|
|
),
|
|
"reference": devis.reference,
|
|
"lignes": [
|
|
{
|
|
"article_code": l.article_code,
|
|
"quantite": l.quantite,
|
|
"remise_pourcentage": l.remise_pourcentage,
|
|
}
|
|
for l in devis.lignes
|
|
],
|
|
}
|
|
|
|
# Appel HTTP vers Windows
|
|
resultat = sage_client.creer_devis(devis_data)
|
|
|
|
logger.info(f"✅ Devis créé: {resultat.get('numero_devis')}")
|
|
|
|
return DevisResponse(
|
|
id=resultat["numero_devis"],
|
|
client_id=devis.client_id,
|
|
date_devis=resultat["date_devis"],
|
|
montant_total_ht=resultat["total_ht"],
|
|
montant_total_ttc=resultat["total_ttc"],
|
|
nb_lignes=resultat["nb_lignes"],
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur création devis: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/devis/{id}", tags=["Devis"])
|
|
async def modifier_devis(
|
|
id: str,
|
|
devis_update: DevisUpdateRequest,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
update_data = {}
|
|
|
|
if devis_update.date_devis:
|
|
update_data["date_devis"] = devis_update.date_devis.isoformat()
|
|
|
|
if devis_update.lignes is not None:
|
|
update_data["lignes"] = [
|
|
{
|
|
"article_code": l.article_code,
|
|
"quantite": l.quantite,
|
|
"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:
|
|
commande_data = {
|
|
"client_id": commande.client_id,
|
|
"date_commande": (
|
|
commande.date_commande.isoformat() if commande.date_commande else None
|
|
),
|
|
"date_livraison": (
|
|
commande.date_livraison.isoformat() if commande.date_livraison else None
|
|
),
|
|
"reference": commande.reference,
|
|
"lignes": [
|
|
{
|
|
"article_code": l.article_code,
|
|
"quantite": l.quantite,
|
|
"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:
|
|
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,
|
|
"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),
|
|
inclure_lignes: bool = Query(
|
|
True, description="Inclure les lignes de chaque devis"
|
|
),
|
|
):
|
|
try:
|
|
devis_list = sage_client.lister_devis(
|
|
limit=limit, statut=statut, inclure_lignes=inclure_lignes
|
|
)
|
|
return devis_list
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste devis: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/devis/{id}", tags=["Devis"])
|
|
async def lire_devis(id: str):
|
|
try:
|
|
devis = sage_client.lire_devis(id)
|
|
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
return {"success": True, "data": devis}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture devis: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/devis/{id}/pdf", tags=["Devis"])
|
|
async def telecharger_devis_pdf(id: str):
|
|
try:
|
|
# Générer PDF en appelant la méthode de email_queue
|
|
# qui elle-même appellera sage_client pour récupérer les données
|
|
pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS)
|
|
|
|
return StreamingResponse(
|
|
iter([pdf_bytes]),
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition": f"attachment; filename=Devis_{id}.pdf"},
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Erreur génération PDF: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"])
|
|
async def telecharger_document_pdf(
|
|
type_doc: int = Path(
|
|
...,
|
|
description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)",
|
|
),
|
|
numero: str = Path(..., description="Numéro du document"),
|
|
):
|
|
try:
|
|
# 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)
|
|
):
|
|
try:
|
|
tous_destinataires = [request.destinataire] + request.cc + request.cci
|
|
email_logs = []
|
|
|
|
for dest in tous_destinataires:
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=dest,
|
|
sujet=request.sujet,
|
|
corps_html=request.corps_html,
|
|
document_ids=id,
|
|
type_document=TypeDocument.DEVIS,
|
|
statut=StatutEmailEnum.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
|
|
session.add(email_log)
|
|
await session.flush()
|
|
|
|
email_queue.enqueue(email_log.id)
|
|
email_logs.append(email_log.id)
|
|
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"✅ Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"email_log_ids": email_logs,
|
|
"devis_id": id,
|
|
"message": f"{len(tous_destinataires)} email(s) en file d'attente",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur envoi email: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.put("/devis/{id}/statut", tags=["Devis"])
|
|
async def changer_statut_devis(
|
|
id: str,
|
|
nouveau_statut: int = Query(
|
|
..., ge=0, le=6, description="0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé"
|
|
),
|
|
):
|
|
try:
|
|
# 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é: {statut_actuel} → {nouveau_statut}")
|
|
|
|
return {
|
|
"success": True,
|
|
"devis_id": id,
|
|
"statut_ancien": resultat.get("statut_ancien", statut_actuel),
|
|
"statut_nouveau": resultat.get("statut_nouveau", nouveau_statut),
|
|
"message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur changement statut devis {id}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/commandes/{id}", tags=["Commandes"])
|
|
async def lire_commande(id: str):
|
|
try:
|
|
commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE)
|
|
if not commande:
|
|
raise HTTPException(404, f"Commande {id} introuvable")
|
|
return commande
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture commande: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/commandes", tags=["Commandes"])
|
|
async def lister_commandes(
|
|
limit: int = Query(100, le=1000), statut: Optional[int] = Query(None)
|
|
):
|
|
try:
|
|
commandes = sage_client.lister_commandes(limit=limit, statut=statut)
|
|
return commandes
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste commandes: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"])
|
|
async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)):
|
|
try:
|
|
# É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,
|
|
type_source=TypeDocument.DEVIS,
|
|
document_cible=resultat.get("document_cible", ""),
|
|
type_cible=TypeDocument.BON_COMMANDE,
|
|
nb_lignes=resultat.get("nb_lignes", 0),
|
|
date_transformation=datetime.now(),
|
|
succes=True,
|
|
)
|
|
|
|
session.add(workflow_log)
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"✅ Transformation: Devis {id} → Commande {resultat['document_cible']}"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"document_source": id,
|
|
"document_cible": resultat["document_cible"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
"statut_devis_mis_a_jour": True,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur transformation: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"])
|
|
async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)):
|
|
try:
|
|
resultat = sage_client.transformer_document(
|
|
numero_source=id,
|
|
type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10
|
|
type_cible=settings.SAGE_TYPE_FACTURE, # = 60
|
|
)
|
|
|
|
workflow_log = WorkflowLog(
|
|
id=str(uuid.uuid4()),
|
|
document_source=id,
|
|
type_source=TypeDocument.BON_COMMANDE,
|
|
document_cible=resultat.get("document_cible", ""),
|
|
type_cible=TypeDocument.FACTURE,
|
|
nb_lignes=resultat.get("nb_lignes", 0),
|
|
date_transformation=datetime.now(),
|
|
succes=True,
|
|
)
|
|
|
|
session.add(workflow_log)
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"✅ Transformation: Commande {id} → Facture {resultat['document_cible']}"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"document_source": id,
|
|
"document_cible": resultat["document_cible"],
|
|
"nb_lignes": resultat["nb_lignes"],
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur transformation: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
def normaliser_type_doc(type_doc: int) -> int:
|
|
TYPES_AUTORISES = {0, 10, 30, 50, 60}
|
|
|
|
if type_doc not in TYPES_AUTORISES:
|
|
raise ValueError(
|
|
f"type_doc invalide ({type_doc}). Valeurs autorisées : {sorted(TYPES_AUTORISES)}"
|
|
)
|
|
|
|
return type_doc if type_doc == 0 else type_doc // 10
|
|
|
|
|
|
@app.post("/signature/universign/send", tags=["Signatures"])
|
|
async def envoyer_signature_optimise(
|
|
demande: SignatureRequest, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
# Récupérer le document depuis Sage
|
|
doc = sage_client.lire_document(
|
|
demande.doc_id, normaliser_type_doc(demande.type_doc)
|
|
)
|
|
if not doc:
|
|
raise HTTPException(404, f"Document {demande.doc_id} introuvable")
|
|
|
|
# Générer PDF
|
|
pdf_bytes = email_queue._generate_pdf(
|
|
demande.doc_id, normaliser_type_doc(demande.type_doc)
|
|
)
|
|
|
|
# Préparer les données du document pour l'email
|
|
doc_data = {
|
|
"type_doc": demande.type_doc,
|
|
"type_label": {
|
|
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:
|
|
raise HTTPException(500, resultat["error"])
|
|
|
|
# Logger en DB
|
|
signature_log = SignatureLog(
|
|
id=str(uuid.uuid4()),
|
|
document_id=demande.doc_id,
|
|
type_document=demande.type_doc,
|
|
transaction_id=resultat["transaction_id"],
|
|
signer_url=resultat["signer_url"],
|
|
email_signataire=demande.email_signataire,
|
|
nom_signataire=demande.nom_signataire,
|
|
statut=StatutSignatureEnum.ENVOYE,
|
|
date_envoi=datetime.now(),
|
|
)
|
|
|
|
session.add(signature_log)
|
|
await session.commit()
|
|
|
|
# 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} (Email: {resultat['email_sent']})"
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"transaction_id": resultat["transaction_id"],
|
|
"signer_url": resultat["signer_url"],
|
|
"email_sent": resultat["email_sent"],
|
|
"email_log_id": resultat.get("email_log_id"),
|
|
"message": f"Demande de signature envoyée à {demande.email_signataire}",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur signature: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/webhooks/universign", tags=["Signatures"])
|
|
async def webhook_universign(
|
|
request: Request, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
payload = await request.json()
|
|
|
|
event_type = payload.get("event")
|
|
transaction_id = payload.get("transaction_id")
|
|
|
|
if not transaction_id:
|
|
logger.warning("⚠️ Webhook sans transaction_id")
|
|
return {"status": "ignored"}
|
|
|
|
# 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(...)):
|
|
# Chercher dans la DB locale
|
|
try:
|
|
async with async_session_factory() as session:
|
|
query = select(SignatureLog).where(SignatureLog.document_id == docId)
|
|
result = await session.execute(query)
|
|
signature_log = result.scalar_one_or_none()
|
|
|
|
if not signature_log:
|
|
raise HTTPException(404, "Signature introuvable")
|
|
|
|
# Interroger Universign
|
|
statut = await universign_statut(signature_log.transaction_id)
|
|
|
|
return {
|
|
"doc_id": docId,
|
|
"statut": statut["statut"],
|
|
"date_signature": statut.get("date_signature"),
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur statut signature: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/signatures", tags=["Signatures"])
|
|
async def lister_signatures(
|
|
statut: Optional[StatutSignature] = Query(None),
|
|
limit: int = Query(100, le=1000),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc())
|
|
|
|
if statut:
|
|
statut_db = StatutSignatureEnum[statut.value]
|
|
query = query.where(SignatureLog.statut == statut_db)
|
|
|
|
query = query.limit(limit)
|
|
result = await session.execute(query)
|
|
signatures = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": sig.id,
|
|
"document_id": sig.document_id,
|
|
"type_document": sig.type_document.value,
|
|
"transaction_id": sig.transaction_id,
|
|
"signer_url": sig.signer_url,
|
|
"email_signataire": sig.email_signataire,
|
|
"nom_signataire": sig.nom_signataire,
|
|
"statut": sig.statut.value,
|
|
"date_envoi": sig.date_envoi.isoformat() if sig.date_envoi else None,
|
|
"date_signature": (
|
|
sig.date_signature.isoformat() if sig.date_signature else None
|
|
),
|
|
"est_relance": sig.est_relance,
|
|
"nb_relances": sig.nb_relances or 0,
|
|
}
|
|
for sig in signatures
|
|
]
|
|
|
|
|
|
@app.get("/signatures/{transaction_id}/status", tags=["Signatures"])
|
|
async def statut_signature_detail(
|
|
transaction_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id)
|
|
result = await session.execute(query)
|
|
signature_log = result.scalar_one_or_none()
|
|
|
|
if not signature_log:
|
|
raise HTTPException(404, f"Transaction {transaction_id} introuvable")
|
|
|
|
# Interroger Universign
|
|
statut_universign = await universign_statut(transaction_id)
|
|
|
|
if statut_universign.get("statut") != "ERREUR":
|
|
statut_map = {
|
|
"EN_ATTENTE": StatutSignatureEnum.EN_ATTENTE,
|
|
"ENVOYE": StatutSignatureEnum.ENVOYE,
|
|
"SIGNE": StatutSignatureEnum.SIGNE,
|
|
"REFUSE": StatutSignatureEnum.REFUSE,
|
|
"EXPIRE": StatutSignatureEnum.EXPIRE,
|
|
}
|
|
|
|
nouveau_statut = statut_map.get(
|
|
statut_universign["statut"], StatutSignatureEnum.EN_ATTENTE
|
|
)
|
|
|
|
signature_log.statut = nouveau_statut
|
|
|
|
if statut_universign.get("date_signature"):
|
|
signature_log.date_signature = datetime.fromisoformat(
|
|
statut_universign["date_signature"].replace("Z", "+00:00")
|
|
)
|
|
|
|
await session.commit()
|
|
|
|
return {
|
|
"transaction_id": transaction_id,
|
|
"document_id": signature_log.document_id,
|
|
"statut": signature_log.statut.value,
|
|
"email_signataire": signature_log.email_signataire,
|
|
"date_envoi": (
|
|
signature_log.date_envoi.isoformat() if signature_log.date_envoi else None
|
|
),
|
|
"date_signature": (
|
|
signature_log.date_signature.isoformat()
|
|
if signature_log.date_signature
|
|
else None
|
|
),
|
|
"signer_url": signature_log.signer_url,
|
|
}
|
|
|
|
|
|
@app.post("/signatures/refresh-all", tags=["Signatures"])
|
|
async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)):
|
|
query = select(SignatureLog).where(
|
|
SignatureLog.statut.in_(
|
|
[StatutSignatureEnum.EN_ATTENTE, StatutSignatureEnum.ENVOYE]
|
|
)
|
|
)
|
|
|
|
result = await session.execute(query)
|
|
signatures = result.scalars().all()
|
|
nb_mises_a_jour = 0
|
|
|
|
for sig in signatures:
|
|
try:
|
|
statut_universign = await universign_statut(sig.transaction_id)
|
|
|
|
if statut_universign.get("statut") != "ERREUR":
|
|
statut_map = {
|
|
"SIGNE": StatutSignatureEnum.SIGNE,
|
|
"REFUSE": StatutSignatureEnum.REFUSE,
|
|
"EXPIRE": StatutSignatureEnum.EXPIRE,
|
|
}
|
|
|
|
nouveau = statut_map.get(statut_universign["statut"])
|
|
|
|
if nouveau and nouveau != sig.statut:
|
|
sig.statut = nouveau
|
|
|
|
if statut_universign.get("date_signature"):
|
|
sig.date_signature = datetime.fromisoformat(
|
|
statut_universign["date_signature"].replace("Z", "+00:00")
|
|
)
|
|
|
|
nb_mises_a_jour += 1
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur refresh signature {sig.transaction_id}: {e}")
|
|
continue
|
|
|
|
await session.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"nb_signatures_verifiees": len(signatures),
|
|
"nb_mises_a_jour": nb_mises_a_jour,
|
|
}
|
|
|
|
|
|
@app.post("/devis/{id}/signer", tags=["Devis"])
|
|
async def envoyer_devis_signature(
|
|
id: str, request: SignatureRequest, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
# Vérifier devis via gateway Windows
|
|
devis = sage_client.lire_devis(id)
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
# Générer PDF
|
|
pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS)
|
|
|
|
# Envoi Universign
|
|
resultat = await universign_envoyer(
|
|
id, pdf_bytes, request.email_signataire, request.nom_signataire
|
|
)
|
|
|
|
if "error" in resultat:
|
|
raise HTTPException(500, f"Erreur Universign: {resultat['error']}")
|
|
|
|
# Logger en DB
|
|
signature_log = SignatureLog(
|
|
id=str(uuid.uuid4()),
|
|
document_id=id,
|
|
type_document=TypeDocument.DEVIS,
|
|
transaction_id=resultat["transaction_id"],
|
|
signer_url=resultat["signer_url"],
|
|
email_signataire=request.email_signataire,
|
|
nom_signataire=request.nom_signataire,
|
|
statut=StatutSignatureEnum.ENVOYE,
|
|
date_envoi=datetime.now(),
|
|
)
|
|
|
|
session.add(signature_log)
|
|
await session.commit()
|
|
|
|
# MAJ champ libre Sage via gateway
|
|
sage_client.mettre_a_jour_champ_libre(
|
|
id, TypeDocument.DEVIS, "UniversignID", resultat["transaction_id"]
|
|
)
|
|
|
|
return {
|
|
"success": True,
|
|
"devis_id": id,
|
|
"transaction_id": resultat["transaction_id"],
|
|
"signer_url": resultat["signer_url"],
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur envoi signature: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
class EmailBatchRequest(BaseModel):
|
|
destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100)
|
|
sujet: str = Field(..., min_length=1, max_length=500)
|
|
corps_html: str = Field(..., min_length=1)
|
|
document_ids: Optional[List[str]] = None
|
|
type_document: Optional[TypeDocument] = None
|
|
|
|
|
|
@app.post("/emails/send-batch", tags=["Emails"])
|
|
async def envoyer_emails_lot(
|
|
batch: EmailBatchRequest, session: AsyncSession = Depends(get_session)
|
|
):
|
|
resultats = []
|
|
|
|
for destinataire in batch.destinataires:
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=destinataire,
|
|
sujet=batch.sujet,
|
|
corps_html=batch.corps_html,
|
|
document_ids=",".join(batch.document_ids) if batch.document_ids else None,
|
|
type_document=batch.type_document,
|
|
statut=StatutEmailEnum.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
|
|
session.add(email_log)
|
|
await session.flush()
|
|
|
|
email_queue.enqueue(email_log.id)
|
|
|
|
resultats.append(
|
|
{
|
|
"destinataire": destinataire,
|
|
"log_id": email_log.id,
|
|
"statut": "EN_ATTENTE",
|
|
}
|
|
)
|
|
|
|
await session.commit()
|
|
|
|
nb_documents = len(batch.document_ids) if batch.document_ids else 0
|
|
|
|
logger.info(
|
|
f"✅ {len(batch.destinataires)} emails mis en file avec {nb_documents} docs"
|
|
)
|
|
|
|
return {
|
|
"total": len(batch.destinataires),
|
|
"succes": len(batch.destinataires),
|
|
"documents_attaches": nb_documents,
|
|
"details": resultats,
|
|
}
|
|
|
|
|
|
@app.post(
|
|
"/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]
|
|
)
|
|
async def valider_remise(
|
|
client_id: str = Query(..., min_length=1),
|
|
remise_pourcentage: float = Query(0.0, ge=0, le=100),
|
|
):
|
|
try:
|
|
remise_max = sage_client.lire_remise_max_client(client_id)
|
|
|
|
autorisee = remise_pourcentage <= remise_max
|
|
|
|
if not autorisee:
|
|
message = f"⚠️ Remise trop élevée (max autorisé: {remise_max}%)"
|
|
logger.warning(
|
|
f"Remise refusée pour {client_id}: {remise_pourcentage}% > {remise_max}%"
|
|
)
|
|
else:
|
|
message = "✅ Remise autorisée"
|
|
|
|
return BaremeRemiseResponse(
|
|
client_id=client_id,
|
|
remise_max_autorisee=remise_max,
|
|
remise_demandee=remise_pourcentage,
|
|
autorisee=autorisee,
|
|
message=message,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur validation remise: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.post("/devis/{id}/relancer-signature", tags=["Devis"])
|
|
async def relancer_devis_signature(
|
|
id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
# Lire devis via gateway
|
|
devis = sage_client.lire_devis(id)
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
# Récupérer contact via gateway
|
|
contact = sage_client.lire_contact_client(devis["client_code"])
|
|
if not contact or not contact.get("email"):
|
|
raise HTTPException(400, "Aucun email trouvé pour ce client")
|
|
|
|
# Générer PDF
|
|
pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS)
|
|
|
|
# Envoi Universign
|
|
resultat = await universign_envoyer(
|
|
id,
|
|
pdf_bytes,
|
|
contact["email"],
|
|
contact["nom"] or contact["client_intitule"],
|
|
)
|
|
|
|
if "error" in resultat:
|
|
raise HTTPException(500, resultat["error"])
|
|
|
|
# Logger en DB
|
|
signature_log = SignatureLog(
|
|
id=str(uuid.uuid4()),
|
|
document_id=id,
|
|
type_document=TypeDocument.DEVIS,
|
|
transaction_id=resultat["transaction_id"],
|
|
signer_url=resultat["signer_url"],
|
|
email_signataire=contact["email"],
|
|
nom_signataire=contact["nom"] or contact["client_intitule"],
|
|
statut=StatutSignatureEnum.ENVOYE,
|
|
date_envoi=datetime.now(),
|
|
est_relance=True,
|
|
nb_relances=1,
|
|
)
|
|
|
|
session.add(signature_log)
|
|
await session.commit()
|
|
|
|
return {
|
|
"success": True,
|
|
"devis_id": id,
|
|
"transaction_id": resultat["transaction_id"],
|
|
"message": "Relance signature envoyée",
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur relance: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
class ContactClientResponse(BaseModel):
|
|
client_code: str
|
|
client_intitule: str
|
|
email: Optional[str]
|
|
nom: Optional[str]
|
|
telephone: Optional[str]
|
|
peut_etre_relance: bool
|
|
|
|
|
|
@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"])
|
|
async def recuperer_contact_devis(id: str):
|
|
try:
|
|
# Lire devis via gateway Windows
|
|
devis = sage_client.lire_devis(id)
|
|
if not devis:
|
|
raise HTTPException(404, f"Devis {id} introuvable")
|
|
|
|
# Lire contact via gateway Windows
|
|
contact = sage_client.lire_contact_client(devis["client_code"])
|
|
if not contact:
|
|
raise HTTPException(
|
|
404, f"Contact introuvable pour client {devis['client_code']}"
|
|
)
|
|
|
|
peut_relancer = bool(contact.get("email"))
|
|
|
|
return ContactClientResponse(**contact, peut_etre_relance=peut_relancer)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur récupération contact: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
# =====================================================
|
|
# ENDPOINTS - US-A7
|
|
# =====================================================
|
|
|
|
|
|
@app.get("/factures", tags=["Factures"])
|
|
async def lister_factures(
|
|
limit: int = Query(100, le=1000), statut: Optional[int] = Query(None)
|
|
):
|
|
try:
|
|
factures = sage_client.lister_factures(limit=limit, statut=statut)
|
|
return factures
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur liste factures: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/factures/{numero}", tags=["Factures"])
|
|
async def lire_facture_detail(numero: str):
|
|
try:
|
|
facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE)
|
|
|
|
if not facture:
|
|
raise HTTPException(404, f"Facture {numero} introuvable")
|
|
|
|
return {"success": True, "data": facture}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur lecture facture {numero}: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
class RelanceFactureRequest(BaseModel):
|
|
doc_id: str
|
|
message_personnalise: Optional[str] = None
|
|
|
|
|
|
@app.post("/factures", status_code=201, tags=["Factures"])
|
|
async def creer_facture(
|
|
facture: FactureCreateRequest, session: AsyncSession = Depends(get_session)
|
|
):
|
|
try:
|
|
facture_data = {
|
|
"client_id": facture.client_id,
|
|
"date_facture": (
|
|
facture.date_facture.isoformat() if facture.date_facture else None
|
|
),
|
|
"date_livraison": (
|
|
facture.date_livraison.isoformat() if facture.date_livraison else None
|
|
),
|
|
"reference": facture.reference,
|
|
"lignes": [
|
|
{
|
|
"article_code": l.article_code,
|
|
"quantite": l.quantite,
|
|
"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:
|
|
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,
|
|
"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",
|
|
"nom": "Relance Facture",
|
|
"sujet": "Rappel - Facture {{DO_Piece}}",
|
|
"corps_html": """
|
|
<p>Bonjour {{CT_Intitule}},</p>
|
|
<p>La facture <strong>{{DO_Piece}}</strong> du {{DO_Date}}
|
|
d'un montant de <strong>{{DO_TotalTTC}}€ TTC</strong> reste impayée.</p>
|
|
<p>Merci de régulariser dans les meilleurs délais.</p>
|
|
<p>Cordialement,</p>
|
|
""",
|
|
"variables_disponibles": [
|
|
"DO_Piece",
|
|
"DO_Date",
|
|
"CT_Intitule",
|
|
"DO_TotalHT",
|
|
"DO_TotalTTC",
|
|
],
|
|
}
|
|
}
|
|
|
|
|
|
@app.post("/factures/{id}/relancer", tags=["Factures"])
|
|
async def relancer_facture(
|
|
id: str,
|
|
relance: RelanceFactureRequest,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
# Lire facture via gateway Windows
|
|
facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE)
|
|
if not facture:
|
|
raise HTTPException(404, f"Facture {id} introuvable")
|
|
|
|
# Récupérer contact via gateway Windows
|
|
contact = sage_client.lire_contact_client(facture["client_code"])
|
|
if not contact or not contact.get("email"):
|
|
raise HTTPException(400, "Aucun email trouvé pour ce client")
|
|
|
|
# Préparer email
|
|
template = templates_email_db["relance_facture"]
|
|
|
|
variables = {
|
|
"DO_Piece": facture.get("numero", id),
|
|
"DO_Date": str(facture.get("date", "")),
|
|
"CT_Intitule": facture.get("client_intitule", ""),
|
|
"DO_TotalHT": f"{facture.get('total_ht', 0):.2f}",
|
|
"DO_TotalTTC": f"{facture.get('total_ttc', 0):.2f}",
|
|
}
|
|
|
|
sujet = template["sujet"]
|
|
corps = relance.message_personnalise or template["corps_html"]
|
|
|
|
for var, valeur in variables.items():
|
|
sujet = sujet.replace(f"{{{{{var}}}}}", valeur)
|
|
corps = corps.replace(f"{{{{{var}}}}}", valeur)
|
|
|
|
# Créer log email
|
|
email_log = EmailLog(
|
|
id=str(uuid.uuid4()),
|
|
destinataire=contact["email"],
|
|
sujet=sujet,
|
|
corps_html=corps,
|
|
document_ids=id,
|
|
type_document=TypeDocument.FACTURE,
|
|
statut=StatutEmailEnum.EN_ATTENTE,
|
|
date_creation=datetime.now(),
|
|
nb_tentatives=0,
|
|
)
|
|
|
|
session.add(email_log)
|
|
await session.flush()
|
|
|
|
# Enqueue
|
|
email_queue.enqueue(email_log.id)
|
|
|
|
sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE)
|
|
|
|
await session.commit()
|
|
|
|
logger.info(f"✅ Relance facture: {id} → {contact['email']}")
|
|
|
|
return {
|
|
"success": True,
|
|
"facture_id": id,
|
|
"email_log_id": email_log.id,
|
|
"destinataire": contact["email"],
|
|
}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Erreur relance facture: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/emails/logs", tags=["Emails"])
|
|
async def journal_emails(
|
|
statut: Optional[StatutEmail] = Query(None),
|
|
destinataire: Optional[str] = Query(None),
|
|
limit: int = Query(100, le=1000),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
query = select(EmailLog)
|
|
|
|
if statut:
|
|
query = query.where(EmailLog.statut == StatutEmailEnum[statut.value])
|
|
|
|
if destinataire:
|
|
query = query.where(EmailLog.destinataire.contains(destinataire))
|
|
|
|
query = query.order_by(EmailLog.date_creation.desc()).limit(limit)
|
|
|
|
result = await session.execute(query)
|
|
logs = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": log.id,
|
|
"destinataire": log.destinataire,
|
|
"sujet": log.sujet,
|
|
"statut": log.statut.value,
|
|
"date_creation": log.date_creation.isoformat(),
|
|
"date_envoi": log.date_envoi.isoformat() if log.date_envoi else None,
|
|
"nb_tentatives": log.nb_tentatives,
|
|
"derniere_erreur": log.derniere_erreur,
|
|
"document_ids": log.document_ids,
|
|
}
|
|
for log in logs
|
|
]
|
|
|
|
|
|
@app.get("/emails/logs/export", tags=["Emails"])
|
|
async def exporter_logs_csv(
|
|
statut: Optional[StatutEmail] = Query(None),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
query = select(EmailLog)
|
|
if statut:
|
|
query = query.where(EmailLog.statut == StatutEmailEnum[statut.value])
|
|
|
|
query = query.order_by(EmailLog.date_creation.desc())
|
|
|
|
result = await session.execute(query)
|
|
logs = result.scalars().all()
|
|
|
|
# Génération CSV
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
|
|
# En-têtes
|
|
writer.writerow(
|
|
[
|
|
"ID",
|
|
"Destinataire",
|
|
"Sujet",
|
|
"Statut",
|
|
"Date Création",
|
|
"Date Envoi",
|
|
"Nb Tentatives",
|
|
"Erreur",
|
|
"Documents",
|
|
]
|
|
)
|
|
|
|
# Données
|
|
for log in logs:
|
|
writer.writerow(
|
|
[
|
|
log.id,
|
|
log.destinataire,
|
|
log.sujet,
|
|
log.statut.value,
|
|
log.date_creation.isoformat(),
|
|
log.date_envoi.isoformat() if log.date_envoi else "",
|
|
log.nb_tentatives,
|
|
log.derniere_erreur or "",
|
|
log.document_ids or "",
|
|
]
|
|
)
|
|
|
|
output.seek(0)
|
|
|
|
return StreamingResponse(
|
|
iter([output.getvalue()]),
|
|
media_type="text/csv",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename=emails_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
|
},
|
|
)
|
|
|
|
|
|
class TemplateEmail(BaseModel):
|
|
id: Optional[str] = None
|
|
nom: str
|
|
sujet: str
|
|
corps_html: str
|
|
variables_disponibles: List[str] = []
|
|
|
|
|
|
class TemplatePreviewRequest(BaseModel):
|
|
template_id: str
|
|
document_id: str
|
|
type_document: TypeDocument
|
|
|
|
|
|
@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"])
|
|
async def lister_templates():
|
|
return [TemplateEmail(**template) for template in templates_email_db.values()]
|
|
|
|
|
|
@app.get(
|
|
"/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"]
|
|
)
|
|
async def lire_template(template_id: str):
|
|
if template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {template_id} introuvable")
|
|
|
|
return TemplateEmail(**templates_email_db[template_id])
|
|
|
|
|
|
@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"])
|
|
async def creer_template(template: TemplateEmail):
|
|
template_id = str(uuid.uuid4())
|
|
|
|
templates_email_db[template_id] = {
|
|
"id": template_id,
|
|
"nom": template.nom,
|
|
"sujet": template.sujet,
|
|
"corps_html": template.corps_html,
|
|
"variables_disponibles": template.variables_disponibles,
|
|
}
|
|
|
|
logger.info(f"Template créé: {template_id}")
|
|
|
|
return TemplateEmail(id=template_id, **template.dict())
|
|
|
|
|
|
@app.put(
|
|
"/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"]
|
|
)
|
|
async def modifier_template(template_id: str, template: TemplateEmail):
|
|
if template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {template_id} introuvable")
|
|
|
|
# Ne pas modifier les templates système
|
|
if template_id in ["relance_devis", "relance_facture"]:
|
|
raise HTTPException(400, "Les templates système ne peuvent pas être modifiés")
|
|
|
|
templates_email_db[template_id] = {
|
|
"id": template_id,
|
|
"nom": template.nom,
|
|
"sujet": template.sujet,
|
|
"corps_html": template.corps_html,
|
|
"variables_disponibles": template.variables_disponibles,
|
|
}
|
|
|
|
logger.info(f"Template modifié: {template_id}")
|
|
|
|
return TemplateEmail(id=template_id, **template.dict())
|
|
|
|
|
|
@app.delete("/templates/emails/{template_id}", tags=["Emails"])
|
|
async def supprimer_template(template_id: str):
|
|
if template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {template_id} introuvable")
|
|
|
|
if template_id in ["relance_devis", "relance_facture"]:
|
|
raise HTTPException(400, "Les templates système ne peuvent pas être supprimés")
|
|
|
|
del templates_email_db[template_id]
|
|
|
|
logger.info(f"Template supprimé: {template_id}")
|
|
|
|
return {"success": True, "message": f"Template {template_id} supprimé"}
|
|
|
|
|
|
@app.post("/templates/emails/preview", tags=["Emails"])
|
|
async def previsualiser_email(preview: TemplatePreviewRequest):
|
|
if preview.template_id not in templates_email_db:
|
|
raise HTTPException(404, f"Template {preview.template_id} introuvable")
|
|
|
|
template = templates_email_db[preview.template_id]
|
|
|
|
# Lire document via gateway Windows
|
|
doc = sage_client.lire_document(preview.document_id, preview.type_document)
|
|
if not doc:
|
|
raise HTTPException(404, f"Document {preview.document_id} introuvable")
|
|
|
|
# Variables
|
|
variables = {
|
|
"DO_Piece": doc.get("numero", preview.document_id),
|
|
"DO_Date": str(doc.get("date", "")),
|
|
"CT_Intitule": doc.get("client_intitule", ""),
|
|
"DO_TotalHT": f"{doc.get('total_ht', 0):.2f}",
|
|
"DO_TotalTTC": f"{doc.get('total_ttc', 0):.2f}",
|
|
}
|
|
|
|
# Fusion
|
|
sujet_preview = template["sujet"]
|
|
corps_preview = template["corps_html"]
|
|
|
|
for var, valeur in variables.items():
|
|
sujet_preview = sujet_preview.replace(f"{{{{{var}}}}}", valeur)
|
|
corps_preview = corps_preview.replace(f"{{{{{var}}}}}", valeur)
|
|
|
|
return {
|
|
"template_id": preview.template_id,
|
|
"document_id": preview.document_id,
|
|
"sujet": sujet_preview,
|
|
"corps_html": corps_preview,
|
|
"variables_utilisees": variables,
|
|
}
|
|
|
|
|
|
# =====================================================
|
|
# ENDPOINTS - HEALTH
|
|
# =====================================================
|
|
@app.get("/health", tags=["System"])
|
|
async def health_check():
|
|
gateway_health = sage_client.health()
|
|
|
|
return {
|
|
"status": "healthy",
|
|
"sage_gateway": gateway_health,
|
|
"email_queue": {
|
|
"running": email_queue.running,
|
|
"workers": len(email_queue.workers),
|
|
"queue_size": email_queue.queue.qsize(),
|
|
},
|
|
"timestamp": datetime.now().isoformat(),
|
|
}
|
|
|
|
|
|
@app.get("/", tags=["System"])
|
|
async def root():
|
|
return {
|
|
"api": "Sage 100c Dataven - VPS Linux",
|
|
"version": "2.0.0",
|
|
"documentation": "/docs",
|
|
"health": "/health",
|
|
}
|
|
|
|
|
|
# =====================================================
|
|
# ENDPOINTS - ADMIN
|
|
# =====================================================
|
|
|
|
|
|
@app.get("/admin/cache/info", tags=["Admin"])
|
|
async def info_cache():
|
|
try:
|
|
cache_info = sage_client.get_cache_info()
|
|
return cache_info
|
|
|
|
except Exception as e:
|
|
logger.error(f"Erreur info cache: {e}")
|
|
raise HTTPException(500, str(e))
|
|
|
|
|
|
@app.get("/admin/queue/status", tags=["Admin"])
|
|
async def statut_queue():
|
|
return {
|
|
"queue_size": email_queue.queue.qsize(),
|
|
"workers": len(email_queue.workers),
|
|
"running": email_queue.running,
|
|
}
|
|
|
|
|
|
# =====================================================
|
|
# 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:
|
|
avoir_data = {
|
|
"client_id": avoir.client_id,
|
|
"date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None),
|
|
"date_livraison": (
|
|
avoir.date_livraison.isoformat() if avoir.date_livraison else None
|
|
),
|
|
"reference": avoir.reference,
|
|
"lignes": [
|
|
{
|
|
"article_code": l.article_code,
|
|
"quantite": l.quantite,
|
|
"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:
|
|
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,
|
|
"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:
|
|
livraison_data = {
|
|
"client_id": livraison.client_id,
|
|
"date_livraison": (
|
|
livraison.date_livraison.isoformat()
|
|
if livraison.date_livraison
|
|
else None
|
|
),
|
|
"date_livraison_prevue": (
|
|
livraison.date_livraison_prevue.isoformat()
|
|
if livraison.date_livraison_prevue
|
|
else None
|
|
),
|
|
"reference": livraison.reference,
|
|
"lignes": [
|
|
{
|
|
"article_code": l.article_code,
|
|
"quantite": l.quantite,
|
|
"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:
|
|
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,
|
|
"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
|
|
# =====================================================
|
|
if __name__ == "__main__":
|
|
uvicorn.run(
|
|
"api:app",
|
|
host=settings.api_host,
|
|
port=settings.api_port,
|
|
reload=settings.api_reload,
|
|
)
|