Sage100-vps/api.py

4058 lines
131 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body
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
prix_unitaire_ht: Optional[float] = None
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class DevisRequest(BaseModel):
client_id: str
date_devis: Optional[date] = None
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
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",
"lignes": [
{
"article_code": "ART001",
"quantite": 5.0,
"prix_unitaire_ht": 100.0,
"remise_pourcentage": 10.0,
}
],
"statut": 2,
}
}
class LigneCommande(BaseModel):
"""Ligne de commande"""
article_code: str
quantite: float
prix_unitaire_ht: Optional[float] = None
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class CommandeCreateRequest(BaseModel):
"""Création d'une commande"""
client_id: str
date_commande: Optional[date] = None
lignes: List[LigneCommande]
reference: Optional[str] = None # Référence externe
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_commande": "2024-01-15",
"reference": "CMD-EXT-001",
"lignes": [
{
"article_code": "ART001",
"quantite": 10.0,
"prix_unitaire_ht": 50.0,
"remise_pourcentage": 5.0,
}
],
}
}
class CommandeUpdateRequest(BaseModel):
"""Modification d'une commande existante"""
date_commande: Optional[date] = None
lignes: Optional[List[LigneCommande]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"lignes": [
{
"article_code": "ART001",
"quantite": 15.0,
"prix_unitaire_ht": 45.0,
}
],
"statut": 2,
}
}
class LigneLivraison(BaseModel):
"""Ligne de livraison"""
article_code: str
quantite: float
prix_unitaire_ht: Optional[float] = None
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class LivraisonCreateRequest(BaseModel):
"""Création d'une livraison"""
client_id: str
date_livraison: Optional[date] = None
lignes: List[LigneLivraison]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_livraison": "2024-01-15",
"reference": "BL-EXT-001",
"lignes": [
{
"article_code": "ART001",
"quantite": 10.0,
"prix_unitaire_ht": 50.0,
"remise_pourcentage": 5.0,
}
],
}
}
class LivraisonUpdateRequest(BaseModel):
"""Modification d'une livraison existante"""
date_livraison: Optional[date] = None
lignes: Optional[List[LigneLivraison]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"lignes": [
{
"article_code": "ART001",
"quantite": 15.0,
"prix_unitaire_ht": 45.0,
}
],
"statut": 2,
}
}
class LigneAvoir(BaseModel):
"""Ligne d'avoir"""
article_code: str
quantite: float
prix_unitaire_ht: Optional[float] = None
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class AvoirCreateRequest(BaseModel):
"""Création d'un avoir"""
client_id: str
date_avoir: Optional[date] = None
lignes: List[LigneAvoir]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_avoir": "2024-01-15",
"reference": "AV-EXT-001",
"lignes": [
{
"article_code": "ART001",
"quantite": 5.0,
"prix_unitaire_ht": 50.0,
"remise_pourcentage": 0.0,
}
],
}
}
class AvoirUpdateRequest(BaseModel):
"""Modification d'un avoir existant"""
date_avoir: Optional[date] = None
lignes: Optional[List[LigneAvoir]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"lignes": [
{
"article_code": "ART001",
"quantite": 10.0,
"prix_unitaire_ht": 45.0,
}
],
"statut": 2,
}
}
class LigneFacture(BaseModel):
"""Ligne de facture"""
article_code: str
quantite: float
prix_unitaire_ht: Optional[float] = None
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class FactureCreateRequest(BaseModel):
"""Création d'une facture"""
client_id: str
date_facture: Optional[date] = None
lignes: List[LigneFacture]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_facture": "2024-01-15",
"reference": "FA-EXT-001",
"lignes": [
{
"article_code": "ART001",
"quantite": 10.0,
"prix_unitaire_ht": 50.0,
"remise_pourcentage": 5.0,
}
],
}
}
class FactureUpdateRequest(BaseModel):
"""Modification d'une facture existante"""
date_facture: Optional[date] = None
lignes: Optional[List[LigneFacture]] = None
statut: Optional[int] = Field(None, ge=0, le=6)
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"lignes": [
{
"article_code": "ART001",
"quantite": 15.0,
"prix_unitaire_ht": 45.0,
}
],
"statut": 2,
}
}
class ArticleCreateRequest(BaseModel):
"""Schéma pour création d'article"""
reference: str = Field(..., max_length=18, description="Référence article")
designation: str = Field(..., max_length=69, description="Désignation")
famille: Optional[str] = Field(None, max_length=18, description="Code famille")
prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT")
prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT")
stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial")
stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum")
code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres")
unite_vente: Optional[str] = Field("UN", max_length=4, description="Unité")
tva_code: Optional[str] = Field(None, max_length=5, description="Code TVA")
description: Optional[str] = Field(None, description="Description")
class ArticleUpdateRequest(BaseModel):
"""Schéma pour modification d'article"""
designation: Optional[str] = Field(None, max_length=69)
prix_vente: Optional[float] = Field(None, ge=0)
prix_achat: Optional[float] = Field(None, ge=0)
stock_reel: Optional[float] = Field(None, ge=0, description="⚠️ Critique pour erreur 2881")
stock_mini: Optional[float] = Field(None, ge=0)
code_ean: Optional[str] = Field(None, max_length=13)
description: Optional[str] = Field(None)
# =====================================================
# SERVICES EXTERNES (Universign)
# =====================================================
async def universign_envoyer(
doc_id: str, pdf_bytes: bytes, email: str, nom: str
) -> Dict:
"""Envoi signature via API Universign"""
import requests
try:
api_key = settings.universign_api_key
api_url = settings.universign_api_url
auth = (api_key, "")
# Étape 1: Créer transaction
response = requests.post(
f"{api_url}/transactions",
auth=auth,
json={"name": f"Devis {doc_id}", "language": "fr"},
timeout=30,
)
response.raise_for_status()
transaction_id = response.json().get("id")
# Étape 2: Upload PDF
files = {"file": (f"Devis_{doc_id}.pdf", pdf_bytes, "application/pdf")}
response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30)
response.raise_for_status()
file_id = response.json().get("id")
# Étape 3: Ajouter document
response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents",
auth=auth,
data={"document": file_id},
timeout=30,
)
response.raise_for_status()
document_id = response.json().get("id")
# Étape 4: Créer champ signature
response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields",
auth=auth,
data={"type": "signature"},
timeout=30,
)
response.raise_for_status()
field_id = response.json().get("id")
# Étape 5: Assigner signataire
response = requests.post(
f"{api_url}/transactions/{transaction_id}/signatures",
auth=auth,
data={"signer": email, "field": field_id},
timeout=30,
)
response.raise_for_status()
# Étape 6: Démarrer transaction
response = requests.post(
f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30
)
response.raise_for_status()
final_data = response.json()
signer_url = (
final_data.get("actions", [{}])[0].get("url", "")
if final_data.get("actions")
else ""
)
logger.info(f"✅ Signature Universign envoyée: {transaction_id}")
return {
"transaction_id": transaction_id,
"signer_url": signer_url,
"statut": "ENVOYE",
}
except Exception as e:
logger.error(f"❌ Erreur Universign: {e}")
return {"error": str(e), "statut": "ERREUR"}
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)}
# =====================================================
# CYCLE DE VIE
# =====================================================
@asynccontextmanager
async def lifespan(app: FastAPI):
# Init base de données
await init_db()
logger.info("✅ Base de données initialisée")
# ✅ CORRECTION: Injecter session_factory ET sage_client dans email_queue
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)
# =====================================================
# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS)
# =====================================================
@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"])
async def rechercher_clients(query: Optional[str] = Query(None)):
"""🔍 Recherche clients via gateway Windows"""
try:
clients = sage_client.lister_clients(filtre=query or "")
return [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):
"""
📄 Lecture détaillée d'un client par son code
Args:
code: Code du client (ex: "CLI000001", "SARL", etc.)
Returns:
Toutes les informations du client
"""
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),
):
"""
✏️ Modification d'un client existant
Args:
code: Code du client à modifier
client_update: Champs à mettre à jour (seuls les champs fournis seront modifiés)
Returns:
Client modifié avec ses nouvelles valeurs
Example:
PUT /clients/SARL
{
"email": "nouveau@email.fr",
"telephone": "0198765432"
}
"""
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)
):
"""
Création d'un nouveau client dans Sage 100c
"""
try:
nouveau_client = sage_client.creer_client(client.dict())
logger.info(f"✅ Client créé via API: {nouveau_client.get('numero')}")
return {
"success": True,
"message": "Client créé avec succès",
"data": nouveau_client,
}
except Exception as e:
logger.error(f"Erreur lors de la création du client: {e}")
# On renvoie une 400 si c'est une erreur métier (ex: doublon), sinon 500
status = 400 if "existe déjà" in str(e) else 500
raise HTTPException(status, str(e))
@app.get("/articles", response_model=List[ArticleResponse], tags=["Articles"])
async def rechercher_articles(query: Optional[str] = Query(None)):
"""🔍 Recherche articles via gateway Windows"""
try:
articles = sage_client.lister_articles(filtre=query or "")
return [ArticleResponse(**a) for a in articles]
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):
"""
Création d'un nouvel article dans Sage
**Usage typique:** Créer un article avec stock pour éviter l'erreur 2881
**Champs obligatoires:**
- `reference` (max 18 caractères) : Référence unique de l'article
- `designation` (max 69 caractères) : Désignation de l'article
**Champs optionnels mais recommandés:**
- `stock_reel` : Stock initial (important pour éviter erreurs de transformation)
- `prix_vente` : Prix de vente HT
- `unite_vente` : Unité de vente (défaut: "UN")
**Erreurs possibles:**
- 400: Article existe déjà ou données invalides
- 500: Erreur Sage (problème de connexion, champs mal formatés, etc.)
**Exemple:**
```json
{
"reference": "ART001",
"designation": "Article de test",
"prix_vente": 10.50,
"stock_reel": 100.0,
"stock_mini": 10.0,
"unite_vente": "UN",
"tva_code": "C20"
}
```
"""
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"
)
# ⚠️ CORRECTION: Ne pas utiliser exclude_none=True car on veut garder
# les valeurs par défaut (comme unite_vente="UN")
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(...)
):
"""
✏️ Modification complète d'un article existant
**Usage critique:** Augmenter le stock pour résoudre l'erreur 2881
**Erreur 2881 - "L'état du stock ne permet pas de créer la ligne"**
Cette erreur survient lors de la transformation de documents (devis → commande → facture)
lorsque le stock de l'article est insuffisant.
**Solution:** Augmenter le `stock_reel` de l'article
**Exemple - Résoudre l'erreur 2881:**
```json
{
"stock_reel": 100.0
}
```
**Autres modifications possibles:**
- Prix de vente/achat
- Stock minimum
- Code EAN
- Description
**Erreurs possibles:**
- 404: Article introuvable
- 400: Aucun champ à modifier ou données invalides
- 500: Erreur Sage
**Note:** Seuls les champs fournis seront modifiés, les autres restent inchangés
"""
try:
# ✅ CORRECTION: Utiliser exclude_unset=True au lieu de exclude_none=True
# Cela permet de distinguer entre:
# - Champ non fourni (exclu)
# - Champ fourni avec valeur None (inclus pour reset)
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")):
"""
📄 Lecture d'un article spécifique par référence
**Retourne:**
- Toutes les informations de l'article
- Stock actuel (réel, réservé, disponible)
- Prix de vente et d'achat
- Famille, fournisseur principal
- Caractéristiques physiques (poids, volume)
**Source:** Cache mémoire (instantané)
"""
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 = ""):
"""
📋 Liste tous les articles avec filtre optionnel
"""
try:
articles = sage_client.lister_articles(filtre)
return {
"articles": articles,
"total": len(articles)
}
except Exception as e:
logger.error(f"Erreur liste articles: {e}")
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, str(e))
@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["Devis"])
async def creer_devis(devis: DevisRequest):
"""📝 Création de devis via gateway Windows"""
try:
# Préparer les données pour la gateway
devis_data = {
"client_id": devis.client_id,
"date_devis": devis.date_devis.isoformat() if devis.date_devis else None,
"lignes": [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in devis.lignes
],
}
# 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),
):
"""
✏️ Modification d'un devis existant
**Champs modifiables:**
- `date_devis`: Nouvelle date du devis
- `lignes`: Nouvelles lignes (remplace toutes les lignes existantes)
- `statut`: Nouveau statut (0=Brouillon, 2=Accepté, 5=Transformé, 6=Annulé)
**Note importante:**
- Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées
- Pour ajouter/supprimer une ligne, il faut envoyer TOUTES les lignes
- Un devis transformé (statut=5) ne peut plus être modifié
Args:
id: Numéro du devis à modifier
devis_update: Champs à mettre à jour
Returns:
Devis modifié avec ses nouvelles valeurs
"""
try:
# Vérifier que le devis existe
devis_existant = sage_client.lire_devis(id)
if not devis_existant:
raise HTTPException(404, f"Devis {id} introuvable")
# Vérifier qu'il n'est pas déjà transformé
if devis_existant.get("statut") == 5:
raise HTTPException(
400, f"Le devis {id} a déjà été transformé et ne peut plus être modifié"
)
# Construire les données de mise à jour
update_data = {}
if devis_update.date_devis:
update_data["date_devis"] = devis_update.date_devis.isoformat()
if devis_update.lignes is not None:
update_data["lignes"] = [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in devis_update.lignes
]
if devis_update.statut is not None:
update_data["statut"] = devis_update.statut
# 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)
):
"""
Création d'une nouvelle commande (Bon de commande)
**Workflow typique:**
1. Création d'un devis → transformation en commande (automatique)
2. OU création directe d'une commande (cette route)
**Champs obligatoires:**
- `client_id`: Code du client
- `lignes`: Liste des lignes (min 1)
**Champs optionnels:**
- `date_commande`: Date de la commande (par défaut: aujourd'hui)
- `reference`: Référence externe (ex: numéro de commande client)
Args:
commande: Données de la commande à créer
Returns:
Commande créée avec son numéro et ses totaux
"""
try:
# Vérifier que le client existe
client = sage_client.lire_client(commande.client_id)
if not client:
raise HTTPException(404, f"Client {commande.client_id} introuvable")
# Préparer les données pour la gateway
commande_data = {
"client_id": commande.client_id,
"date_commande": (
commande.date_commande.isoformat() if commande.date_commande else None
),
"reference": commande.reference,
"lignes": [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in commande.lignes
],
}
# Appel à la gateway Windows
resultat = sage_client.creer_commande(commande_data)
logger.info(f"✅ Commande créée: {resultat.get('numero_commande')}")
return {
"success": True,
"message": "Commande créée avec succès",
"data": {
"numero_commande": resultat["numero_commande"],
"client_id": commande.client_id,
"date_commande": resultat["date_commande"],
"total_ht": resultat["total_ht"],
"total_ttc": resultat["total_ttc"],
"nb_lignes": resultat["nb_lignes"],
"reference": commande.reference,
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur création commande: {e}")
raise HTTPException(500, str(e))
@app.put("/commandes/{id}", tags=["Commandes"])
async def modifier_commande(
id: str,
commande_update: CommandeUpdateRequest,
session: AsyncSession = Depends(get_session),
):
"""
✏️ Modification d'une commande existante
**Champs modifiables:**
- `date_commande`: Nouvelle date
- `lignes`: Nouvelles lignes (remplace toutes les lignes existantes)
- `statut`: Nouveau statut
- `reference`: Référence externe
**Restrictions:**
- Une commande transformée (statut=5) ne peut plus être modifiée
- Une commande annulée (statut=6) ne peut plus être modifiée
**Note importante:**
Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées
Args:
id: Numéro de la commande à modifier
commande_update: Champs à mettre à jour
Returns:
Commande modifiée avec ses nouvelles valeurs
"""
try:
# Vérifier que la commande existe
commande_existante = sage_client.lire_document(
id, settings.SAGE_TYPE_BON_COMMANDE
)
if not commande_existante:
raise HTTPException(404, f"Commande {id} introuvable")
# Vérifier le statut
statut_actuel = commande_existante.get("statut", 0)
if statut_actuel == 5:
raise HTTPException(
400,
f"La commande {id} a déjà été transformée et ne peut plus être modifiée",
)
if statut_actuel == 6:
raise HTTPException(
400, f"La commande {id} est annulée et ne peut plus être modifiée"
)
# Construire les données de mise à jour
update_data = {}
if commande_update.date_commande:
update_data["date_commande"] = commande_update.date_commande.isoformat()
if commande_update.lignes is not None:
update_data["lignes"] = [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in commande_update.lignes
]
if commande_update.statut is not None:
update_data["statut"] = commande_update.statut
if commande_update.reference is not None:
update_data["reference"] = commande_update.reference
# Appel à la gateway Windows
resultat = sage_client.modifier_commande(id, update_data)
logger.info(f"✅ Commande {id} modifiée avec succès")
return {
"success": True,
"message": f"Commande {id} modifiée avec succès",
"commande": resultat,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur modification commande {id}: {e}")
raise HTTPException(500, str(e))
@app.get("/devis", tags=["Devis"])
async def lister_devis(
limit: int = Query(100, le=1000),
statut: Optional[int] = Query(None),
inclure_lignes: bool = Query(
True, description="Inclure les lignes de chaque devis"
),
):
"""
📋 Liste tous les devis via gateway Windows
Args:
limit: Nombre maximum de devis à retourner
statut: Filtre par statut (0=Devis, 2=Accepté, 5=Transformé, etc.)
inclure_lignes: Si True, charge les lignes de chaque devis (par défaut: True)
✅ AMÉLIORATION: Les lignes sont maintenant incluses par défaut
"""
try:
devis_list = sage_client.lister_devis(
limit=limit, statut=statut, inclure_lignes=inclure_lignes
)
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):
"""
📄 Lecture d'un devis via gateway Windows
Returns:
Devis complet avec:
- Toutes les informations standards
- lignes: Lignes du devis
- a_deja_ete_transforme: ✅ Booléen indiquant si le devis a été transformé
- documents_cibles: ✅ Liste des documents créés depuis ce devis
✅ ENRICHI: Inclut maintenant l'information de transformation
"""
try:
devis = sage_client.lire_devis(id)
if not devis:
raise HTTPException(404, f"Devis {id} introuvable")
# Log informatif
if devis.get("a_deja_ete_transforme"):
docs = devis.get("documents_cibles", [])
logger.info(
f"📊 Devis {id} a été transformé en "
f"{len(docs)} document(s): {[d['numero'] for d in docs]}"
)
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):
"""📄 Téléchargement PDF (généré via email_queue)"""
try:
# Générer PDF en appelant la méthode de email_queue
# qui elle-même appellera sage_client pour récupérer les données
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"),
):
"""
📄 Téléchargement PDF d'un document (route généralisée)
**Types de documents supportés:**
- `0`: Devis
- `10`: Bon de commande
- `30`: Bon de livraison
- `60`: Facture
- `50`: Bon d'avoir
**Exemple d'utilisation:**
- `GET /documents/0/DE00001/pdf` → PDF du devis DE00001
- `GET /documents/60/FA00001/pdf` → PDF de la facture FA00001
**Retour:**
- Fichier PDF prêt à télécharger
- Nom de fichier formaté selon le type de document
Args:
type_doc: Type de document Sage (0-60)
numero: Numéro du document
Returns:
StreamingResponse avec le PDF
"""
try:
# Mapping des types vers les libellés
types_labels = {
0: "Devis",
10: "Commande",
20: "Preparation",
30: "BonLivraison",
40: "BonRetour",
50: "Avoir",
60: "Facture",
}
# Vérifier que le type est valide
if type_doc not in types_labels:
raise HTTPException(
400,
f"Type de document invalide: {type_doc}. "
f"Types valides: {list(types_labels.keys())}",
)
label = types_labels[type_doc]
logger.info(f"📄 Génération PDF: {label} {numero} (type={type_doc})")
# Appel à sage_client pour générer le PDF
pdf_bytes = sage_client.generer_pdf_document(numero, type_doc)
if not pdf_bytes:
raise HTTPException(500, f"Le PDF du document {numero} est vide")
logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets")
# Nom de fichier formaté
filename = f"{label}_{numero}.pdf"
return StreamingResponse(
iter([pdf_bytes]),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"Content-Length": str(len(pdf_bytes)),
},
)
except HTTPException:
raise
except Exception as e:
logger.error(
f"❌ Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True
)
raise HTTPException(500, f"Erreur génération PDF: {str(e)}")
@app.post("/devis/{id}/envoyer", tags=["Devis"])
async def envoyer_devis_email(
id: str, request: EmailEnvoiRequest, session: AsyncSession = Depends(get_session)
):
"""📧 Envoi devis par email"""
try:
# Vérifier que le devis existe
devis = sage_client.lire_devis(id)
if not devis:
raise HTTPException(404, f"Devis {id} introuvable")
# Créer logs email pour chaque destinataire
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é"
),
):
"""
📊 Changement de statut d'un devis
**Statuts possibles:**
- 0: Brouillon
- 2: Accepté/Validé
- 5: Transformé (automatique lors d'une transformation)
- 6: Annulé
**Restrictions:**
- Un devis transformé (5) ne peut plus changer de statut
- Un devis annulé (6) ne peut plus changer de statut
Args:
id: Numéro du devis
nouveau_statut: Nouveau statut (0-6)
Returns:
Confirmation du changement avec ancien et nouveau statut
"""
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))
# =====================================================
# ENDPOINTS - Commandes (WORKFLOW SANS RESSAISIE)
# =====================================================
@app.get("/commandes/{id}", tags=["Commandes"])
async def lire_commande(id: str):
"""📄 Lecture d'une commande avec ses lignes"""
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)
):
"""
📋 Liste toutes les commandes via gateway Windows
✅ CORRECTION : Utilise sage_client.lister_commandes() qui existe déjà
Le filtrage sur type 10 est fait côté Windows dans main.py
"""
try:
commandes = sage_client.lister_commandes(limit=limit, statut=statut)
return commandes
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)):
"""
🔧 Transformation Devis → Commande
✅ CORRECTION : Utilise les VRAIS types Sage (0 → 10)
✅ Met à jour le statut du devis source à 5 (Transformé)
"""
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 2: Mettre à jour le statut du devis à 5 (Transformé)
try:
sage_client.changer_statut_devis(id, nouveau_statut=5)
logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)")
except Exception as e:
logger.warning(
f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}"
)
# On continue même si la MAJ statut échoue
# É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)):
"""
🔧 Transformation Commande → Facture
✅ Utilise les VRAIS types Sage (10 → 60)
"""
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))
# =====================================================
# ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE)
# =====================================================
@app.post("/signature/universign/send", tags=["Signatures"])
async def envoyer_signature(
demande: SignatureRequest, session: AsyncSession = Depends(get_session)
):
"""✍️ Envoi document pour signature Universign"""
try:
# Générer PDF
pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc)
# Envoi Universign
resultat = await universign_envoyer(
demande.doc_id, pdf_bytes, demande.email_signataire, demande.nom_signataire
)
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 via gateway Windows
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}")
return {
"success": True,
"transaction_id": resultat["transaction_id"],
"signer_url": resultat["signer_url"],
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur signature: {e}")
raise HTTPException(500, str(e))
@app.get("/signature/universign/status", tags=["Signatures"])
async def statut_signature(docId: str = Query(...)):
"""🔍 Récupération du statut de signature en temps réel"""
# Chercher dans la DB locale
try:
async with async_session_factory() as session:
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),
):
"""📋 Liste toutes les demandes de signature"""
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)
):
"""🔍 Récupération du statut détaillé d'une signature"""
query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id)
result = await session.execute(query)
signature_log = result.scalar_one_or_none()
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)):
"""🔄 Rafraîchit TOUS les statuts des signatures en attente"""
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)
):
"""✏️ Envoi d'un devis pour signature électronique"""
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))
# ============================================
# US-A4 - ENVOI EMAILS EN LOT
# ============================================
class EmailBatchRequest(BaseModel):
destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100)
sujet: str = Field(..., min_length=1, max_length=500)
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)
):
"""📧 US-A4: Envoi groupé via email_queue"""
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,
}
# =====================================================
# ENDPOINTS - US-A5
# =====================================================
@app.post(
"/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"]
)
async def valider_remise(
client_id: str = Query(..., min_length=1),
remise_pourcentage: float = Query(0.0, ge=0, le=100),
):
"""
💰 US-A5: Validation remise via barème client Sage
"""
try:
# ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows)
remise_max = sage_client.lire_remise_max_client(client_id)
autorisee = remise_pourcentage <= remise_max
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))
# =====================================================
# ENDPOINTS - US-A6 (RELANCE DEVIS)
# =====================================================
@app.post("/devis/{id}/relancer-signature", tags=["Devis"])
async def relancer_devis_signature(
id: str, relance: RelanceDevisRequest, session: AsyncSession = Depends(get_session)
):
"""📧 Relance devis via Universign"""
try:
# Lire devis via gateway
devis = sage_client.lire_devis(id)
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):
"""👤 US-A6: Récupération du contact client associé au devis"""
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)
):
"""
📋 Liste toutes les factures via gateway Windows
✅ CORRECTION : Utilise sage_client.lister_factures() qui existe déjà
Le filtrage sur type 60 est fait côté Windows dans main.py
"""
try:
factures = sage_client.lister_factures(limit=limit, statut=statut)
return factures
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):
"""
📄 Lecture détaillée d'une facture avec ses lignes
Args:
numero: Numéro de la facture (ex: "FA000001")
Returns:
Facture complète avec lignes, client, totaux, etc.
"""
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)
):
"""
Création d'une facture
**Workflow typique:**
1. Commande → Livraison → Facture (transformations successives)
2. OU création directe d'une facture (cette route)
**Champs obligatoires:**
- `client_id`: Code du client
- `lignes`: Liste des lignes (min 1)
**Champs optionnels:**
- `date_facture`: Date de la facture (par défaut: aujourd'hui)
- `reference`: Référence externe (ex: numéro de commande client)
**Notes importantes:**
- Les factures peuvent avoir des champs obligatoires supplémentaires selon la configuration Sage
- Le statut initial est généralement 2 (Accepté/Validé)
- Les factures sont soumises aux règles de numérotation strictes
Args:
facture: Données de la facture à créer
Returns:
Facture créée avec son numéro et ses totaux
"""
try:
# Vérifier que le client existe
client = sage_client.lire_client(facture.client_id)
if not client:
raise HTTPException(404, f"Client {facture.client_id} introuvable")
# Préparer les données pour la gateway
facture_data = {
"client_id": facture.client_id,
"date_facture": (
facture.date_facture.isoformat() if facture.date_facture else None
),
"reference": facture.reference,
"lignes": [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in facture.lignes
],
}
# Appel à la gateway Windows
resultat = sage_client.creer_facture(facture_data)
logger.info(f"✅ Facture créée: {resultat.get('numero_facture')}")
return {
"success": True,
"message": "Facture créée avec succès",
"data": {
"numero_facture": resultat["numero_facture"],
"client_id": facture.client_id,
"date_facture": resultat["date_facture"],
"total_ht": resultat["total_ht"],
"total_ttc": resultat["total_ttc"],
"nb_lignes": resultat["nb_lignes"],
"reference": facture.reference,
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur création facture: {e}")
raise HTTPException(500, str(e))
@app.put("/factures/{id}", tags=["Factures"])
async def modifier_facture(
id: str,
facture_update: FactureUpdateRequest,
session: AsyncSession = Depends(get_session),
):
"""
✏️ Modification d'une facture existante
**Champs modifiables:**
- `date_facture`: Nouvelle date
- `lignes`: Nouvelles lignes (remplace toutes les lignes existantes)
- `statut`: Nouveau statut
- `reference`: Référence externe
**Restrictions IMPORTANTES:**
- Une facture transformée (statut=5) ne peut plus être modifiée
- Une facture annulée (statut=6) ne peut plus être modifiée
- ⚠️ **ATTENTION**: Les factures comptabilisées peuvent être verrouillées par Sage
- Certaines factures peuvent être en lecture seule selon les droits utilisateur
**Note importante:**
Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées
Args:
id: Numéro de la facture à modifier
facture_update: Champs à mettre à jour
Returns:
Facture modifiée avec ses nouvelles valeurs
"""
try:
# Vérifier que la facture existe
facture_existante = sage_client.lire_document(id, settings.SAGE_TYPE_FACTURE)
if not facture_existante:
raise HTTPException(404, f"Facture {id} introuvable")
# Vérifier le statut
statut_actuel = facture_existante.get("statut", 0)
if statut_actuel == 5:
raise HTTPException(
400,
f"La facture {id} a déjà été transformée et ne peut plus être modifiée",
)
if statut_actuel == 6:
raise HTTPException(
400, f"La facture {id} est annulée et ne peut plus être modifiée"
)
# Construire les données de mise à jour
update_data = {}
if facture_update.date_facture:
update_data["date_facture"] = facture_update.date_facture.isoformat()
if facture_update.lignes is not None:
update_data["lignes"] = [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in facture_update.lignes
]
if facture_update.statut is not None:
update_data["statut"] = facture_update.statut
if facture_update.reference is not None:
update_data["reference"] = facture_update.reference
# Appel à la gateway Windows
resultat = sage_client.modifier_facture(id, update_data)
logger.info(f"✅ Facture {id} modifiée avec succès")
return {
"success": True,
"message": f"Facture {id} modifiée avec succès",
"facture": resultat,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur modification facture {id}: {e}")
raise HTTPException(500, str(e))
# Templates email (si pas déjà définis)
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),
):
"""💸 US-A7: Relance facture en un clic"""
try:
# Lire facture via gateway Windows
facture = sage_client.lire_document(id, TypeDocument.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)
# ✅ MAJ champ libre via gateway Windows
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))
# ============================================
# US-A9 - JOURNAL DES E-MAILS
# ============================================
@app.get("/emails/logs", tags=["Emails"])
async def journal_emails(
statut: Optional[StatutEmail] = Query(None),
destinataire: Optional[str] = Query(None),
limit: int = Query(100, le=1000),
session: AsyncSession = Depends(get_session),
):
"""📋 US-A9: Journal des e-mails envoyés"""
query = select(EmailLog)
if statut:
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),
):
"""📥 US-A9: Export CSV des logs d'envoi"""
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"
},
)
# ============================================
# Devis0 - MODÈLES D'E-MAILS
# ============================================
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():
"""📧 Emails: Liste tous les templates d'emails"""
return [TemplateEmail(**template) for template in templates_email_db.values()]
@app.get(
"/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"]
)
async def lire_template(template_id: str):
"""📖 Lecture d'un template par ID"""
if template_id not in templates_email_db:
raise HTTPException(404, f"Template {template_id} introuvable")
return TemplateEmail(**templates_email_db[template_id])
@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"])
async def creer_template(template: TemplateEmail):
""" Création d'un nouveau template"""
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):
"""✏️ Modification d'un template existant"""
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):
"""🗑️ Suppression d'un template"""
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):
"""👁️ US-A10: Prévisualisation email avec fusion variables"""
if preview.template_id not in templates_email_db:
raise HTTPException(404, f"Template {preview.template_id} introuvable")
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():
"""🏥 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():
"""🏠 Page d'accueil"""
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():
"""
📊 Informations sur l'état du cache Windows
"""
try:
# ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows)
cache_info = sage_client.get_cache_info()
return cache_info
except Exception as e:
logger.error(f"Erreur info cache: {e}")
raise HTTPException(500, str(e))
# 🔧 CORRECTION ADMIN: Cache refresh (doit appeler Windows)
@app.post("/admin/cache/refresh", tags=["Admin"])
async def forcer_actualisation():
"""
🔄 Force l'actualisation du cache Windows
"""
try:
# ✅ APPEL VIA SAGE_CLIENT (HTTP vers Windows)
resultat = sage_client.refresh_cache()
cache_info = sage_client.get_cache_info()
return {
"success": True,
"message": "Cache actualisé sur Windows Server",
"info": cache_info,
}
except Exception as e:
logger.error(f"Erreur refresh cache: {e}")
raise HTTPException(500, str(e))
# ✅ RESTE INCHANGÉ: admin/queue/status (local au VPS)
@app.get("/admin/queue/status", tags=["Admin"])
async def statut_queue():
"""
📊 Statut de la queue d'emails (local VPS)
"""
return {
"queue_size": email_queue.queue.qsize(),
"workers": len(email_queue.workers),
"running": email_queue.running,
}
# =====================================================
# ENDPOINTS - PROSPECTS
# =====================================================
@app.get("/prospects", tags=["Prospects"])
async def rechercher_prospects(query: Optional[str] = Query(None)):
"""🔍 Recherche prospects via gateway Windows"""
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):
"""📄 Lecture d'un prospect par code"""
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)):
"""
🔍 Recherche fournisseurs via gateway Windows
"""
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),
):
"""
Création d'un nouveau fournisseur dans Sage 100c
**Champs obligatoires:**
- `intitule`: Raison sociale (max 69 caractères)
**Champs optionnels:**
- `compte_collectif`: Compte comptable (défaut: 401000)
- `num`: Code fournisseur personnalisé (auto-généré si vide)
- `adresse`, `code_postal`, `ville`, `pays`
- `email`, `telephone`
- `siret`, `tva_intra`
**Retour:**
- Fournisseur créé avec son numéro définitif
**Erreurs possibles:**
- 400: Fournisseur existe déjà (doublon)
- 500: Erreur technique Sage
"""
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),
):
"""
✏️ Modification d'un fournisseur existant
Args:
code: Code du fournisseur à modifier
fournisseur_update: Champs à mettre à jour (seuls les champs fournis seront modifiés)
Returns:
Fournisseur modifié avec ses nouvelles valeurs
Example:
PUT /fournisseurs/DUPONT
{
"email": "nouveau@email.fr",
"telephone": "0198765432"
}
"""
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):
"""📄 Lecture d'un fournisseur par code"""
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)
):
"""📋 Liste tous les avoirs via gateway Windows"""
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):
"""📄 Lecture d'un avoir avec ses lignes"""
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)
):
"""
Création d'un avoir (Bon d'avoir)
**Workflow typique:**
1. Retour marchandise → création d'un avoir
2. Geste commercial → création directe d'un avoir (cette route)
**Champs obligatoires:**
- `client_id`: Code du client
- `lignes`: Liste des lignes (min 1)
**Champs optionnels:**
- `date_avoir`: Date de l'avoir (par défaut: aujourd'hui)
- `reference`: Référence externe (ex: numéro de retour)
**Note:** Les montants des avoirs sont généralement négatifs (crédits)
Args:
avoir: Données de l'avoir à créer
Returns:
Avoir créé avec son numéro et ses totaux
"""
try:
# Vérifier que le client existe
client = sage_client.lire_client(avoir.client_id)
if not client:
raise HTTPException(404, f"Client {avoir.client_id} introuvable")
# Préparer les données pour la gateway
avoir_data = {
"client_id": avoir.client_id,
"date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None),
"reference": avoir.reference,
"lignes": [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in avoir.lignes
],
}
# Appel à la gateway Windows
resultat = sage_client.creer_avoir(avoir_data)
logger.info(f"✅ Avoir créé: {resultat.get('numero_avoir')}")
return {
"success": True,
"message": "Avoir créé avec succès",
"data": {
"numero_avoir": resultat["numero_avoir"],
"client_id": avoir.client_id,
"date_avoir": resultat["date_avoir"],
"total_ht": resultat["total_ht"],
"total_ttc": resultat["total_ttc"],
"nb_lignes": resultat["nb_lignes"],
"reference": avoir.reference,
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur création avoir: {e}")
raise HTTPException(500, str(e))
@app.put("/avoirs/{id}", tags=["Avoirs"])
async def modifier_avoir(
id: str,
avoir_update: AvoirUpdateRequest,
session: AsyncSession = Depends(get_session),
):
"""
✏️ Modification d'un avoir existant
**Champs modifiables:**
- `date_avoir`: Nouvelle date
- `lignes`: Nouvelles lignes (remplace toutes les lignes existantes)
- `statut`: Nouveau statut
- `reference`: Référence externe
**Restrictions:**
- Un avoir transformé (statut=5) ne peut plus être modifié
- Un avoir annulé (statut=6) ne peut plus être modifié
**Note importante:**
Si `lignes` est fourni, TOUTES les lignes existantes seront remplacées
Args:
id: Numéro de l'avoir à modifier
avoir_update: Champs à mettre à jour
Returns:
Avoir modifié avec ses nouvelles valeurs
"""
try:
# Vérifier que l'avoir existe
avoir_existant = sage_client.lire_avoir(id)
if not avoir_existant:
raise HTTPException(404, f"Avoir {id} introuvable")
# Vérifier le statut
statut_actuel = avoir_existant.get("statut", 0)
if statut_actuel == 5:
raise HTTPException(
400, f"L'avoir {id} a déjà été transformé et ne peut plus être modifié"
)
if statut_actuel == 6:
raise HTTPException(
400, f"L'avoir {id} est annulé et ne peut plus être modifié"
)
# Construire les données de mise à jour
update_data = {}
if avoir_update.date_avoir:
update_data["date_avoir"] = avoir_update.date_avoir.isoformat()
if avoir_update.lignes is not None:
update_data["lignes"] = [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in avoir_update.lignes
]
if avoir_update.statut is not None:
update_data["statut"] = avoir_update.statut
if avoir_update.reference is not None:
update_data["reference"] = avoir_update.reference
# Appel à la gateway Windows
resultat = sage_client.modifier_avoir(id, update_data)
logger.info(f"✅ Avoir {id} modifié avec succès")
return {
"success": True,
"message": f"Avoir {id} modifié avec succès",
"avoir": resultat,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur modification avoir {id}: {e}")
raise HTTPException(500, str(e))
# =====================================================
# ENDPOINTS - LIVRAISONS
# =====================================================
@app.get("/livraisons", tags=["Livraisons"])
async def lister_livraisons(
limit: int = Query(100, le=1000), statut: Optional[int] = Query(None)
):
"""📋 Liste tous les bons de livraison via gateway Windows"""
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):
"""📄 Lecture d'une livraison avec ses lignes"""
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)
):
"""
Création d'une nouvelle livraison (Bon de livraison)
**Workflow typique:**
1. Création d'une commande → transformation en livraison (automatique)
2. OU création directe d'une livraison (cette route)
**Champs obligatoires:**
- `client_id`: Code du client
- `lignes`: Liste des lignes (min 1)
**Champs optionnels:**
- `date_livraison`: Date de la livraison (par défaut: aujourd'hui)
- `reference`: Référence externe (ex: numéro de commande client)
"""
try:
# Vérifier que le client existe
client = sage_client.lire_client(livraison.client_id)
if not client:
raise HTTPException(404, f"Client {livraison.client_id} introuvable")
# Préparer les données pour la gateway
livraison_data = {
"client_id": livraison.client_id,
"date_livraison": (
livraison.date_livraison.isoformat()
if livraison.date_livraison
else None
),
"reference": livraison.reference,
"lignes": [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in livraison.lignes
],
}
# Appel à la gateway Windows
resultat = sage_client.creer_livraison(livraison_data)
logger.info(f"✅ Livraison créée: {resultat.get('numero_livraison')}")
return {
"success": True,
"message": "Livraison créée avec succès",
"data": {
"numero_livraison": resultat["numero_livraison"],
"client_id": livraison.client_id,
"date_livraison": resultat["date_livraison"],
"total_ht": resultat["total_ht"],
"total_ttc": resultat["total_ttc"],
"nb_lignes": resultat["nb_lignes"],
"reference": livraison.reference,
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur création livraison: {e}")
raise HTTPException(500, str(e))
@app.put("/livraisons/{id}", tags=["Livraisons"])
async def modifier_livraison(
id: str,
livraison_update: LivraisonUpdateRequest,
session: AsyncSession = Depends(get_session),
):
"""
✏️ Modification d'une livraison existante
**Champs modifiables:**
- `date_livraison`: Nouvelle date
- `lignes`: Nouvelles lignes (remplace toutes les lignes existantes)
- `statut`: Nouveau statut
- `reference`: Référence externe
**Restrictions:**
- Une livraison transformée (statut=5) ne peut plus être modifiée
- Une livraison annulée (statut=6) ne peut plus être modifiée
"""
try:
# Vérifier que la livraison existe
livraison_existante = sage_client.lire_livraison(id)
if not livraison_existante:
raise HTTPException(404, f"Livraison {id} introuvable")
# Vérifier le statut
statut_actuel = livraison_existante.get("statut", 0)
if statut_actuel == 5:
raise HTTPException(
400,
f"La livraison {id} a déjà été transformée et ne peut plus être modifiée",
)
if statut_actuel == 6:
raise HTTPException(
400, f"La livraison {id} est annulée et ne peut plus être modifiée"
)
# Construire les données de mise à jour
update_data = {}
if livraison_update.date_livraison:
update_data["date_livraison"] = livraison_update.date_livraison.isoformat()
if livraison_update.lignes is not None:
update_data["lignes"] = [
{
"article_code": l.article_code,
"quantite": l.quantite,
"prix_unitaire_ht": l.prix_unitaire_ht,
"remise_pourcentage": l.remise_pourcentage,
}
for l in livraison_update.lignes
]
if livraison_update.statut is not None:
update_data["statut"] = livraison_update.statut
if livraison_update.reference is not None:
update_data["reference"] = livraison_update.reference
# Appel à la gateway Windows
resultat = sage_client.modifier_livraison(id, update_data)
logger.info(f"✅ Livraison {id} modifiée avec succès")
return {
"success": True,
"message": f"Livraison {id} modifiée avec succès",
"livraison": resultat,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur modification livraison {id}: {e}")
raise HTTPException(500, str(e))
@app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"])
async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)):
"""
🔧 Transformation Livraison → Facture
✅ Utilise les VRAIS types Sage (30 → 60)
"""
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)
):
"""
🔧 Transformation Devis → Facture (DIRECT, sans commande)
✅ Utilise les VRAIS types Sage (0 → 60)
✅ Met à jour le statut du devis source à 5 (Transformé)
**Workflow raccourci** : Permet de facturer directement depuis un devis
sans passer par la création d'une commande.
**Cas d'usage** :
- Prestations de services facturées directement
- Petites commandes sans besoin de suivi intermédiaire
- Ventes au comptoir
Args:
id: Numéro du devis source
Returns:
Informations de la facture créée
"""
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 3: Mettre à jour le statut du devis à 5 (Transformé)
try:
sage_client.changer_statut_devis(id, nouveau_statut=5)
logger.info(f"✅ Statut devis {id} mis à jour: 5 (Transformé)")
except Exception as e:
logger.warning(
f"⚠️ Impossible de mettre à jour le statut du devis {id}: {e}"
)
# On continue même si la MAJ statut échoue
# É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)
):
"""
🔧 Transformation Commande → Bon de livraison
✅ Utilise les VRAIS types Sage (10 → 30)
**Workflow typique** : Après validation d'une commande, génère
le bon de livraison pour préparer l'expédition.
**Cas d'usage** :
- Préparation d'une expédition
- Génération du bordereau de livraison
- Suivi logistique
**Workflow complet** :
1. Devis → Commande (via `/workflow/devis/{id}/to-commande`)
2. **Commande → Livraison** (cette route)
3. Livraison → Facture (via `/workflow/livraison/{id}/to-facture`)
Args:
id: Numéro de la commande source
Returns:
Informations du bon de livraison créé
"""
try:
# Étape 1: Vérifier que la commande existe
commande_existante = sage_client.lire_document(
id, settings.SAGE_TYPE_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("/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),
):
"""
🔓 **ROUTE DEBUG** - Liste tous les utilisateurs inscrits
⚠️ **ATTENTION**: Cette route n'est PAS protégée par authentification.
À utiliser uniquement en développement ou à sécuriser en production.
Args:
limit: Nombre maximum d'utilisateurs à retourner
role: Filtrer par rôle (user, admin, commercial)
verified_only: Afficher uniquement les utilisateurs vérifiés
Returns:
Liste des utilisateurs avec leurs informations (mot de passe masqué)
"""
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)):
"""
📊 **ROUTE DEBUG** - Statistiques sur les utilisateurs
⚠️ Non protégée - à sécuriser en production
"""
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))
# =====================================================
# LANCEMENT
# =====================================================
if __name__ == "__main__":
uvicorn.run(
"api:app",
host=settings.api_host,
port=settings.api_port,
reload=settings.api_reload,
)