Sage100-ws/main.py
2025-12-28 19:21:28 +01:00

2170 lines
68 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, Header, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, validator, EmailStr, field_validator
from typing import Optional, List, Dict
from datetime import datetime, date
from decimal import Decimal
from enum import Enum, IntEnum
import uvicorn
import logging
import win32com.client
import time
from config import settings, validate_settings
from sage_connector import SageConnector
import pyodbc
import os
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[logging.FileHandler("sage_gateway.log"), logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
class TypeDocument(int, Enum):
DEVIS = 0
BON_LIVRAISON = 1
BON_RETOUR = 2
COMMANDE = 3
PREPARATION = 4
FACTURE = 5
class DocumentGetRequest(BaseModel):
numero: str
type_doc: int
class FiltreRequest(BaseModel):
filtre: Optional[str] = ""
class CodeRequest(BaseModel):
code: str
class ChampLibreRequest(BaseModel):
doc_id: str
type_doc: int
nom_champ: str
valeur: str
class DevisRequest(BaseModel):
client_id: str
date_devis: Optional[date] = None
date_livraison: Optional[date] = None
reference: Optional[str] = None
lignes: List[Dict]
class TransformationRequest(BaseModel):
numero_source: str
type_source: int
type_cible: int
class StatutRequest(BaseModel):
nouveau_statut: int
class TypeTiers(IntEnum):
"""CT_Type - Type de tiers"""
CLIENT = 0
FOURNISSEUR = 1
SALARIE = 2
AUTRE = 3
class ClientCreateRequest(BaseModel):
intitule: str = Field(
...,
max_length=69,
description="Nom du client (CT_Intitule) - OBLIGATOIRE"
)
numero: Optional[str] = Field(
None,
max_length=17,
description="Numéro client CT_Num (auto si None)"
)
type_tiers: int = Field(
0,
ge=0,
le=3,
description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre"
)
qualite: Optional[str] = Field(
"CLI",
max_length=17,
description="CT_Qualite: CLI/FOU/SAL/DIV/AUT"
)
classement: Optional[str] = Field(
None,
max_length=17,
description="CT_Classement"
)
raccourci: Optional[str] = Field(
None,
max_length=7,
description="CT_Raccourci (7 chars max, unique)"
)
siret: Optional[str] = Field(
None,
max_length=15,
description="CT_Siret (14-15 chars)"
)
tva_intra: Optional[str] = Field(
None,
max_length=25,
description="CT_Identifiant (TVA intracommunautaire)"
)
code_naf: Optional[str] = Field(
None,
max_length=7,
description="CT_Ape (Code NAF/APE)"
)
contact: Optional[str] = Field(
None,
max_length=35,
description="CT_Contact (double affectation: client + adresse)"
)
adresse: Optional[str] = Field(
None,
max_length=35,
description="Adresse.Adresse"
)
complement: Optional[str] = Field(
None,
max_length=35,
description="Adresse.Complement"
)
code_postal: Optional[str] = Field(
None,
max_length=9,
description="Adresse.CodePostal"
)
ville: Optional[str] = Field(
None,
max_length=35,
description="Adresse.Ville"
)
region: Optional[str] = Field(
None,
max_length=25,
description="Adresse.CodeRegion"
)
pays: Optional[str] = Field(
None,
max_length=35,
description="Adresse.Pays"
)
telephone: Optional[str] = Field(
None,
max_length=21,
description="Telecom.Telephone"
)
telecopie: Optional[str] = Field(
None,
max_length=21,
description="Telecom.Telecopie (fax)"
)
email: Optional[str] = Field(
None,
max_length=69,
description="Telecom.EMail"
)
site_web: Optional[str] = Field(
None,
max_length=69,
description="Telecom.Site"
)
portable: Optional[str] = Field(
None,
max_length=21,
description="Telecom.Portable"
)
facebook: Optional[str] = Field(
None,
max_length=69,
description="Telecom.Facebook ou CT_Facebook"
)
linkedin: Optional[str] = Field(
None,
max_length=69,
description="Telecom.LinkedIn ou CT_LinkedIn"
)
compte_general: Optional[str] = Field(
None,
max_length=13,
description="CompteGPrinc (défaut selon type_tiers: 4110000, 4010000, 421, 471)"
)
categorie_tarifaire: Optional[str] = Field(
None,
description="N_CatTarif (ID catégorie tarifaire, défaut '0' ou '1')"
)
categorie_comptable: Optional[str] = Field(
None,
description="N_CatCompta (ID catégorie comptable, défaut '0' ou '1')"
)
taux01: Optional[float] = Field(None, description="CT_Taux01")
taux02: Optional[float] = Field(None, description="CT_Taux02")
taux03: Optional[float] = Field(None, description="CT_Taux03")
taux04: Optional[float] = Field(None, description="CT_Taux04")
secteur: Optional[str] = Field(
None,
max_length=21,
description="Alias de statistique01 (CT_Statistique01)"
)
statistique01: Optional[str] = Field(None, max_length=21, description="CT_Statistique01")
statistique02: Optional[str] = Field(None, max_length=21, description="CT_Statistique02")
statistique03: Optional[str] = Field(None, max_length=21, description="CT_Statistique03")
statistique04: Optional[str] = Field(None, max_length=21, description="CT_Statistique04")
statistique05: Optional[str] = Field(None, max_length=21, description="CT_Statistique05")
statistique06: Optional[str] = Field(None, max_length=21, description="CT_Statistique06")
statistique07: Optional[str] = Field(None, max_length=21, description="CT_Statistique07")
statistique08: Optional[str] = Field(None, max_length=21, description="CT_Statistique08")
statistique09: Optional[str] = Field(None, max_length=21, description="CT_Statistique09")
statistique10: Optional[str] = Field(None, max_length=21, description="CT_Statistique10")
encours_autorise: Optional[float] = Field(
None,
description="CT_Encours (montant max autorisé)"
)
assurance_credit: Optional[float] = Field(
None,
description="CT_Assurance (montant assurance crédit)"
)
langue: Optional[int] = Field(
None,
ge=0,
description="CT_Langue (0=Français, 1=Anglais, etc.)"
)
commercial_code: Optional[int] = Field(
None,
description="CO_No (ID du collaborateur commercial)"
)
lettrage_auto: Optional[bool] = Field(
True,
description="CT_Lettrage (1=oui, 0=non)"
)
est_actif: Optional[bool] = Field(
True,
description="Inverse de CT_Sommeil (True=actif, False=en sommeil)"
)
type_facture: Optional[int] = Field(
1,
ge=0,
le=2,
description="CT_Facture: 0=aucune, 1=normale, 2=regroupée"
)
est_prospect: Optional[bool] = Field(
False,
description="CT_Prospect (1=oui, 0=non)"
)
bl_en_facture: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_BLFact (impression BL sur facture)"
)
saut_page: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_Saut (saut de page après impression)"
)
validation_echeance: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_ValidEch"
)
controle_encours: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_ControlEnc"
)
exclure_relance: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_NotRappel"
)
exclure_penalites: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_NotPenal"
)
bon_a_payer: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_BonAPayer"
)
priorite_livraison: Optional[int] = Field(
None,
ge=0,
le=5,
description="CT_PrioriteLivr"
)
livraison_partielle: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_LivrPartielle"
)
delai_transport: Optional[int] = Field(
None,
ge=0,
description="CT_DelaiTransport (jours)"
)
delai_appro: Optional[int] = Field(
None,
ge=0,
description="CT_DelaiAppro (jours)"
)
commentaire: Optional[str] = Field(
None,
max_length=35,
description="CT_Commentaire"
)
section_analytique: Optional[str] = Field(
None,
max_length=13,
description="CA_Num"
)
mode_reglement_code: Optional[int] = Field(
None,
description="MR_No (ID du mode de règlement)"
)
surveillance_active: Optional[int] = Field(
None,
ge=0,
le=1,
description="CT_Surveillance (DOIT être défini AVANT coface)"
)
coface: Optional[str] = Field(
None,
max_length=25,
description="CT_Coface (code Coface)"
)
forme_juridique: Optional[str] = Field(
None,
max_length=33,
description="CT_SvFormeJuri (SARL, SA, etc.)"
)
effectif: Optional[str] = Field(
None,
max_length=11,
description="CT_SvEffectif"
)
sv_regularite: Optional[str] = Field(
None,
max_length=3,
description="CT_SvRegul"
)
sv_cotation: Optional[str] = Field(
None,
max_length=5,
description="CT_SvCotation"
)
sv_objet_maj: Optional[str] = Field(
None,
max_length=61,
description="CT_SvObjetMaj"
)
ca_annuel: Optional[float] = Field(
None,
description="CT_SvCA (Chiffre d'affaires annuel) - alias: sv_chiffre_affaires"
)
sv_chiffre_affaires: Optional[float] = Field(
None,
description="CT_SvCA (alias de ca_annuel)"
)
sv_resultat: Optional[float] = Field(
None,
description="CT_SvResultat"
)
@field_validator('siret')
@classmethod
def validate_siret(cls, v):
"""Valide et nettoie le SIRET"""
if v and v.lower() not in ('none', 'null', ''):
cleaned = v.replace(' ', '').replace('-', '')
if len(cleaned) not in (14, 15):
raise ValueError('Le SIRET doit contenir 14 ou 15 caractères')
return cleaned
return None
@field_validator('email')
@classmethod
def validate_email(cls, v):
"""Valide le format email"""
if v and v.lower() not in ('none', 'null', ''):
v = v.strip()
if '@' not in v:
raise ValueError('Format email invalide')
return v
return None
@field_validator('raccourci')
@classmethod
def validate_raccourci(cls, v):
"""Force le raccourci en majuscules"""
if v and v.lower() not in ('none', 'null', ''):
return v.upper().strip()[:7]
return None
@field_validator(
'adresse', 'code_postal', 'ville', 'pays', 'telephone',
'tva_intra', 'contact', 'complement', mode='before'
)
@classmethod
def clean_none_strings(cls, v):
"""Convertit les chaînes 'None'/'null'/'' en None"""
if isinstance(v, str) and v.lower() in ('none', 'null', ''):
return None
return v
def to_sage_dict(self) -> dict:
"""
Convertit le modèle en dictionnaire compatible avec creer_client()
✅ Mapping 1:1 avec les paramètres réels de la fonction
"""
stat01 = self.statistique01 or self.secteur
ca = self.ca_annuel or self.sv_chiffre_affaires
return {
"intitule": self.intitule,
"numero": self.numero,
"type_tiers": self.type_tiers,
"qualite": self.qualite,
"classement": self.classement,
"raccourci": self.raccourci,
"siret": self.siret,
"tva_intra": self.tva_intra,
"code_naf": self.code_naf,
"contact": self.contact,
"adresse": self.adresse,
"complement": self.complement,
"code_postal": self.code_postal,
"ville": self.ville,
"region": self.region,
"pays": self.pays,
"telephone": self.telephone,
"telecopie": self.telecopie,
"email": self.email,
"site_web": self.site_web,
"portable": self.portable,
"facebook": self.facebook,
"linkedin": self.linkedin,
"compte_general": self.compte_general,
"categorie_tarifaire": self.categorie_tarifaire,
"categorie_comptable": self.categorie_comptable,
"taux01": self.taux01,
"taux02": self.taux02,
"taux03": self.taux03,
"taux04": self.taux04,
"statistique01": stat01,
"statistique02": self.statistique02,
"statistique03": self.statistique03,
"statistique04": self.statistique04,
"statistique05": self.statistique05,
"statistique06": self.statistique06,
"statistique07": self.statistique07,
"statistique08": self.statistique08,
"statistique09": self.statistique09,
"statistique10": self.statistique10,
"secteur": self.secteur, # Gardé pour compatibilité
"encours_autorise": self.encours_autorise,
"assurance_credit": self.assurance_credit,
"langue": self.langue,
"commercial_code": self.commercial_code,
"lettrage_auto": self.lettrage_auto,
"est_actif": self.est_actif,
"type_facture": self.type_facture,
"est_prospect": self.est_prospect,
"bl_en_facture": self.bl_en_facture,
"saut_page": self.saut_page,
"validation_echeance": self.validation_echeance,
"controle_encours": self.controle_encours,
"exclure_relance": self.exclure_relance,
"exclure_penalites": self.exclure_penalites,
"bon_a_payer": self.bon_a_payer,
"priorite_livraison": self.priorite_livraison,
"livraison_partielle": self.livraison_partielle,
"delai_transport": self.delai_transport,
"delai_appro": self.delai_appro,
"commentaire": self.commentaire,
"section_analytique": self.section_analytique,
"mode_reglement_code": self.mode_reglement_code,
"surveillance_active": self.surveillance_active,
"coface": self.coface,
"forme_juridique": self.forme_juridique,
"effectif": self.effectif,
"sv_regularite": self.sv_regularite,
"sv_cotation": self.sv_cotation,
"sv_objet_maj": self.sv_objet_maj,
"ca_annuel": ca,
"sv_chiffre_affaires": self.sv_chiffre_affaires,
"sv_resultat": self.sv_resultat,
}
class Config:
json_schema_extra = {
"example": {
"intitule": "ENTREPRISE EXEMPLE SARL",
"numero": "CLI00123",
"type_tiers": 0,
"qualite": "CLI",
"compte_general": "411000",
"est_prospect": False,
"est_actif": True,
"email": "contact@exemple.fr",
"telephone": "0123456789",
"adresse": "123 Rue de la Paix",
"code_postal": "75001",
"ville": "Paris",
"pays": "France"
}
}
class ClientUpdateGatewayRequest(BaseModel):
"""Modèle pour modification client côté gateway"""
code: str
client_data: Dict
class FournisseurCreateRequest(BaseModel):
intitule: str = Field(..., description="Raison sociale du fournisseur")
compte_collectif: str = Field("401000", description="Compte général rattaché")
num: Optional[str] = Field(None, description="Code fournisseur (auto si vide)")
adresse: Optional[str] = None
code_postal: Optional[str] = None
ville: Optional[str] = None
pays: Optional[str] = None
email: Optional[str] = None
telephone: Optional[str] = None
siret: Optional[str] = None
tva_intra: Optional[str] = None
class FournisseurCreateRequest(BaseModel):
intitule: str = Field(..., description="Raison sociale du fournisseur")
compte_collectif: str = Field("401000", description="Compte général rattaché")
num: Optional[str] = Field(None, description="Code fournisseur (auto si vide)")
adresse: Optional[str] = None
code_postal: Optional[str] = None
ville: Optional[str] = None
pays: Optional[str] = None
email: Optional[str] = None
telephone: Optional[str] = None
siret: Optional[str] = None
tva_intra: Optional[str] = None
class FournisseurUpdateGatewayRequest(BaseModel):
"""Modèle pour modification fournisseur côté gateway"""
code: str
fournisseur_data: Dict
class DevisUpdateGatewayRequest(BaseModel):
"""Modèle pour modification devis côté gateway"""
numero: str
devis_data: Dict
class CommandeCreateRequest(BaseModel):
"""Création d'une commande"""
client_id: str
date_commande: Optional[date] = None
date_livraison: Optional[date] = None
reference: Optional[str] = None
lignes: List[Dict]
class CommandeUpdateGatewayRequest(BaseModel):
"""Modèle pour modification commande côté gateway"""
numero: str
commande_data: Dict
class LivraisonCreateGatewayRequest(BaseModel):
"""Création d'une livraison côté gateway"""
client_id: str
date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None
lignes: List[Dict]
reference: Optional[str] = None
class LivraisonUpdateGatewayRequest(BaseModel):
"""Modèle pour modification livraison côté gateway"""
numero: str
livraison_data: Dict
class AvoirCreateGatewayRequest(BaseModel):
"""Création d'un avoir côté gateway"""
client_id: str
date_avoir: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[Dict]
reference: Optional[str] = None
class AvoirUpdateGatewayRequest(BaseModel):
"""Modèle pour modification avoir côté gateway"""
numero: str
avoir_data: Dict
class FactureCreateGatewayRequest(BaseModel):
"""Création d'une facture côté gateway"""
client_id: str
date_facture: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[Dict]
reference: Optional[str] = None
class FactureUpdateGatewayRequest(BaseModel):
"""Modèle pour modification facture côté gateway"""
numero: str
facture_data: Dict
class PDFGenerationRequest(BaseModel):
"""Modèle pour génération PDF"""
doc_id: str = Field(..., description="Numéro du document")
type_doc: int = Field(..., ge=0, le=60, description="Type de document Sage")
class ArticleCreateRequest(BaseModel):
reference: str = Field(..., description="Référence article (max 18 car)")
designation: str = Field(..., description="Désignation (max 69 car)")
famille: Optional[str] = Field(None, 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, description="Code-barres EAN")
unite_vente: Optional[str] = Field("UN", description="Unité de vente")
tva_code: Optional[str] = Field(None, description="Code TVA")
description: Optional[str] = Field(None, description="Description/Commentaire")
class ArticleUpdateGatewayRequest(BaseModel):
"""Modèle pour modification article côté gateway"""
reference: str
article_data: Dict
class MouvementStockLigneRequest(BaseModel):
article_ref: str = Field(..., description="Référence de l'article")
quantite: float = Field(..., gt=0, description="Quantité (>0)")
depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')")
prix_unitaire: Optional[float] = Field(
None, ge=0, description="Prix unitaire (optionnel)"
)
commentaire: Optional[str] = Field(None, description="Commentaire ligne")
numero_lot: Optional[str] = Field(
None, description="Numéro de lot (pour FIFO/LIFO)"
)
stock_mini: Optional[float] = Field(
None,
ge=0,
description="""Stock minimum à définir pour cet article.
Si fourni, met à jour AS_QteMini dans F_ARTSTOCK.
Laisser None pour ne pas modifier.""",
)
stock_maxi: Optional[float] = Field(
None,
ge=0,
description="""Stock maximum à définir pour cet article.
Doit être > stock_mini si les deux sont fournis.""",
)
class Config:
schema_extra = {
"example": {
"article_ref": "ARTS-001",
"quantite": 50.0,
"depot_code": "01",
"prix_unitaire": 100.0,
"commentaire": "Réapprovisionnement",
"numero_lot": "LOT20241217",
"stock_mini": 10.0,
"stock_maxi": 200.0,
}
}
@validator("stock_maxi")
def validate_stock_maxi(cls, v, values):
"""Valide que stock_maxi > stock_mini si les deux sont fournis"""
if (
v is not None
and "stock_mini" in values
and values["stock_mini"] is not None
):
if v <= values["stock_mini"]:
raise ValueError(
"stock_maxi doit être strictement supérieur à stock_mini"
)
return v
class EntreeStockRequest(BaseModel):
"""Création d'un bon d'entrée en stock"""
date_entree: Optional[date] = Field(
None, description="Date du mouvement (aujourd'hui par défaut)"
)
reference: Optional[str] = Field(None, description="Référence externe")
depot_code: Optional[str] = Field(
None, description="Dépôt principal (si applicable)"
)
lignes: List[MouvementStockLigneRequest] = Field(
..., min_items=1, description="Lignes du mouvement"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")
class SortieStockRequest(BaseModel):
"""Création d'un bon de sortie de stock"""
date_sortie: Optional[date] = Field(
None, description="Date du mouvement (aujourd'hui par défaut)"
)
reference: Optional[str] = Field(None, description="Référence externe")
depot_code: Optional[str] = Field(
None, description="Dépôt principal (si applicable)"
)
lignes: List[MouvementStockLigneRequest] = Field(
..., min_items=1, description="Lignes du mouvement"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")
class FamilleCreate(BaseModel):
"""Modèle pour créer une famille d'articles"""
code: str = Field(..., description="Code famille (max 18 car)", max_length=18)
intitule: str = Field(..., description="Intitulé (max 69 car)", max_length=69)
type: int = Field(0, description="0=Détail, 1=Total")
compte_achat: Optional[str] = Field(
None, description="Compte général d'achat (ex: 607000)"
)
compte_vente: Optional[str] = Field(
None, description="Compte général de vente (ex: 707000)"
)
class ContactCreateRequest(BaseModel):
"""Requête de création de contact"""
numero: str
civilite: Optional[str] = None
nom: str
prenom: Optional[str] = None
fonction: Optional[str] = None
service_code: Optional[int] = None
telephone: Optional[str] = None
portable: Optional[str] = None
telecopie: Optional[str] = None
email: Optional[str] = None
facebook: Optional[str] = None
linkedin: Optional[str] = None
skype: Optional[str] = None
class ContactListRequest(BaseModel):
"""Requête de liste des contacts"""
numero: str
class ContactGetRequest(BaseModel):
"""Requête de récupération d'un contact"""
numero: str
contact_numero: int
class ContactUpdateRequest(BaseModel):
"""Requête de modification d'un contact"""
numero: str
contact_numero: int
updates: Dict
class ContactDeleteRequest(BaseModel):
"""Requête de suppression d'un contact"""
numero: str
contact_numero: int
def verify_token(x_sage_token: str = Header(...)):
"""Vérification du token d'authentification"""
if x_sage_token != settings.sage_gateway_token:
logger.warning(f" Token invalide reçu: {x_sage_token[:20]}...")
raise HTTPException(401, "Token invalide")
return True
app = FastAPI(
title="Sage Gateway - Windows Server",
version="1.0.0",
description="Passerelle d'accès à Sage 100c pour VPS Linux",
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
sage: Optional[SageConnector] = None
@app.on_event("startup")
def startup():
global sage
logger.info("🚀 Démarrage Sage Gateway Windows...")
try:
validate_settings()
logger.info(" Configuration validée")
except ValueError as e:
logger.error(f" Configuration invalide: {e}")
raise
sage = SageConnector(
settings.chemin_base, settings.utilisateur, settings.mot_de_passe
)
if not sage.connecter():
raise RuntimeError(" Impossible de se connecter à Sage 100c")
logger.info(" Sage Gateway démarré et connecté")
@app.on_event("shutdown")
def shutdown():
if sage:
sage.deconnecter()
logger.info("👋 Sage Gateway arrêté")
@app.get("/health")
def health():
"""Health check"""
return {
"status": "ok",
"sage_connected": sage is not None and sage.cial is not None,
"cache_info": sage.get_cache_info() if sage else None,
"timestamp": datetime.now().isoformat(),
}
@app.post("/sage/clients/list", dependencies=[Depends(verify_token)])
def clients_list(req: FiltreRequest):
"""Liste des clients avec filtre optionnel"""
try:
clients = sage.lister_tous_clients(req.filtre)
return {"success": True, "data": clients}
except Exception as e:
logger.error(f"Erreur liste clients: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/clients/update", dependencies=[Depends(verify_token)])
def modifier_client_endpoint(req: ClientUpdateGatewayRequest):
try:
resultat = sage.modifier_client(req.code, req.client_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification client: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification client: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/clients/get", dependencies=[Depends(verify_token)])
def client_get(req: CodeRequest):
"""Lecture d'un client par code"""
try:
client = sage.lire_client(req.code)
if not client:
raise HTTPException(404, f"Client {req.code} non trouvé")
return {"success": True, "data": client}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture client: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/clients/create", dependencies=[Depends(verify_token)])
def create_client_endpoint(req: ClientCreateRequest):
"""Création d'un client dans Sage"""
try:
resultat = sage.creer_client(req.dict())
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier création client: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur technique création client: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/articles/list", dependencies=[Depends(verify_token)])
def articles_list(req: FiltreRequest):
"""Liste des articles avec filtre optionnel"""
try:
articles = sage.lister_tous_articles(req.filtre)
return {"success": True, "data": articles}
except Exception as e:
logger.error(f"Erreur liste articles: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/articles/get", dependencies=[Depends(verify_token)])
def article_get(req: CodeRequest):
"""Lecture d'un article par référence"""
try:
article = sage.lire_article(req.code)
if not article:
raise HTTPException(404, f"Article {req.code} non trouvé")
return {"success": True, "data": article}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture article: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/devis/create", dependencies=[Depends(verify_token)])
def creer_devis(req: DevisRequest):
"""Création d'un devis"""
try:
devis_data = {
"client": {"code": req.client_id, "intitule": ""},
"date_devis": req.date_devis or date.today(),
"date_livraison": req.date_livraison or date.today(),
"reference": req.reference,
"lignes": req.lignes,
}
resultat = sage.creer_devis_enrichi(devis_data)
return {"success": True, "data": resultat}
except Exception as e:
logger.error(f"Erreur création devis: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/devis/get", dependencies=[Depends(verify_token)])
def lire_devis(req: CodeRequest):
try:
devis = sage.lire_devis(req.code)
if not devis:
raise HTTPException(404, f"Devis {req.code} non trouvé")
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.post("/sage/devis/list", dependencies=[Depends(verify_token)])
def devis_list(
limit: int = Query(1000, description="Nombre max de devis"),
statut: Optional[int] = Query(None, description="Filtrer par statut"),
filtre: str = Query("", description="Filtre texte (numero, client)"),
):
try:
devis_list = sage.lister_tous_devis_cache(filtre)
if statut is not None:
devis_list = [d for d in devis_list if d.get("statut") == statut]
devis_list = devis_list[:limit]
logger.info(f" {len(devis_list)} devis retournés depuis le cache")
return {"success": True, "data": devis_list}
except Exception as e:
logger.error(f" Erreur liste devis: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/devis/statut", dependencies=[Depends(verify_token)])
def changer_statut_devis_endpoint(numero: str, nouveau_statut: int):
"""Change le statut d'un devis"""
try:
with sage._com_context(), sage._lock_com:
factory = sage.cial.FactoryDocumentVente
persist = factory.ReadPiece(0, numero)
if not persist:
raise HTTPException(404, f"Devis {numero} introuvable")
doc = win32com.client.CastTo(persist, "IBODocumentVente3")
doc.Read()
statut_actuel = getattr(doc, "DO_Statut", 0)
doc.DO_Statut = nouveau_statut
doc.Write()
logger.info(f" Statut devis {numero}: {statut_actuel}{nouveau_statut}")
return {
"success": True,
"data": {
"numero": numero,
"statut_ancien": statut_actuel,
"statut_nouveau": nouveau_statut,
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur changement statut: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/documents/get", dependencies=[Depends(verify_token)])
def lire_document(req: DocumentGetRequest):
"""Lecture d'un document (commande, facture, etc.)"""
try:
doc = sage.lire_document(req.numero, req.type_doc)
if not doc:
raise HTTPException(404, f"Document {req.numero} non trouvé")
return {"success": True, "data": doc}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture document: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/documents/transform", dependencies=[Depends(verify_token)])
def transformer_document(
numero_source: str = Query(..., description="Numéro du document source"),
type_source: int = Query(..., description="Type document source"),
type_cible: int = Query(..., description="Type document cible"),
):
try:
logger.info(
f"🔄 Transformation demandée: {numero_source} "
f"(type {type_source}) → type {type_cible}"
)
transformations_valides = {
(0, 10), # Devis → Commande
(10, 30), # Commande → Bon de livraison
(10, 60), # Commande → Facture
(30, 60), # Bon de livraison → Facture
(0, 60), # Devis → Facture (si autorisé)
}
if (type_source, type_cible) not in transformations_valides:
logger.error(
f" Transformation non autorisée: {type_source}{type_cible}"
)
raise HTTPException(
400,
f"Transformation non autorisée: type {type_source} → type {type_cible}. "
f"Transformations valides: {transformations_valides}",
)
resultat = sage.transformer_document(numero_source, type_source, type_cible)
logger.info(
f" Transformation réussie: {numero_source}"
f"{resultat.get('document_cible', '?')} "
f"({resultat.get('nb_lignes', 0)} lignes)"
)
return {"success": True, "data": resultat}
except HTTPException:
raise
except ValueError as e:
logger.error(f" Erreur métier transformation: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f" Erreur technique transformation: {e}", exc_info=True)
raise HTTPException(500, f"Erreur transformation: {str(e)}")
@app.post("/sage/documents/champ-libre", dependencies=[Depends(verify_token)])
def maj_champ_libre(req: ChampLibreRequest):
try:
success = sage.mettre_a_jour_champ_libre(
req.doc_id, req.type_doc, req.nom_champ, req.valeur
)
return {"success": success}
except Exception as e:
logger.error(f"Erreur MAJ champ libre: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/documents/derniere-relance", dependencies=[Depends(verify_token)])
def maj_derniere_relance(doc_id: str, type_doc: int):
try:
success = sage.mettre_a_jour_derniere_relance(doc_id, type_doc)
return {"success": success}
except Exception as e:
logger.error(f"Erreur MAJ dernière relance: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/contact/read", dependencies=[Depends(verify_token)])
def contact_read(req: CodeRequest):
"""Lecture du contact principal d'un client"""
try:
contact = sage.lire_contact_principal_client(req.code)
if not contact:
raise HTTPException(404, f"Contact non trouvé pour client {req.code}")
return {"success": True, "data": contact}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture contact: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/commandes/list", dependencies=[Depends(verify_token)])
def commandes_list(
limit: int = Query(100, description="Nombre max de commandes"),
statut: Optional[int] = Query(None, description="Filtrer par statut"),
filtre: str = Query("", description="Filtre texte"),
):
try:
commandes = sage.lister_toutes_commandes_cache(filtre)
return {"success": True, "data": commandes}
except Exception as e:
logger.error(f" Erreur liste commandes: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/factures/list", dependencies=[Depends(verify_token)])
def factures_list(
limit: int = Query(100, description="Nombre max de factures"),
statut: Optional[int] = Query(None, description="Filtrer par statut"),
filtre: str = Query("", description="Filtre texte"),
):
try:
factures = sage.lister_toutes_factures_cache(filtre)
if statut is not None:
factures = [f for f in factures if f.get("statut") == statut]
factures = factures[:limit]
logger.info(f" {len(factures)} factures retournées depuis le cache")
return {"success": True, "data": factures}
except Exception as e:
logger.error(f" Erreur liste factures: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/client/remise-max", dependencies=[Depends(verify_token)])
def lire_remise_max_client(code: str):
"""Récupère la remise max autorisée pour un client"""
try:
client_obj = sage._lire_client_obj(code)
if not client_obj:
raise HTTPException(404, f"Client {code} introuvable")
remise_max = 10.0 # Défaut
try:
remise_max = float(getattr(client_obj, "CT_RemiseMax", 10.0))
except:
pass
logger.info(f" Remise max client {code}: {remise_max}%")
return {
"success": True,
"data": {"client_code": code, "remise_max": remise_max},
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture remise: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/cache/refresh", dependencies=[Depends(verify_token)])
def refresh_cache():
"""Force le rafraîchissement du cache"""
try:
sage.forcer_actualisation_cache()
return {
"success": True,
"message": "Cache actualisé",
"info": sage.get_cache_info(),
}
except Exception as e:
logger.error(f"Erreur refresh cache: {e}")
raise HTTPException(500, str(e))
@app.get("/sage/cache/info", dependencies=[Depends(verify_token)])
def cache_info_get():
"""Informations sur le cache (endpoint GET)"""
try:
return {"success": True, "data": sage.get_cache_info()}
except Exception as e:
logger.error(f"Erreur info cache: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/prospects/list", dependencies=[Depends(verify_token)])
def prospects_list(req: FiltreRequest):
try:
prospects = sage.lister_tous_prospects(req.filtre)
return {"success": True, "data": prospects}
except Exception as e:
logger.error(f"Erreur liste prospects: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/prospects/get", dependencies=[Depends(verify_token)])
def prospect_get(req: CodeRequest):
try:
prospect = sage.lire_prospect(req.code)
if not prospect:
raise HTTPException(404, f"Prospect {req.code} non trouvé")
return {"success": True, "data": prospect}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture prospect: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/fournisseurs/list", dependencies=[Depends(verify_token)])
def fournisseurs_list(req: FiltreRequest):
try:
fournisseurs = sage.lister_tous_fournisseurs_cache(req.filtre)
logger.info(f" {len(fournisseurs)} fournisseurs retournés depuis le cache")
return {"success": True, "data": fournisseurs}
except Exception as e:
logger.error(f" Erreur liste fournisseurs: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/fournisseurs/create", dependencies=[Depends(verify_token)])
def create_fournisseur_endpoint(req: FournisseurCreateRequest):
try:
resultat = sage.creer_fournisseur(req.dict())
logger.info(f" Fournisseur créé: {resultat.get('numero')}")
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"⚠️ Erreur métier création fournisseur: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f" Erreur technique création fournisseur: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/fournisseurs/update", dependencies=[Depends(verify_token)])
def modifier_fournisseur_endpoint(req: FournisseurUpdateGatewayRequest):
try:
resultat = sage.modifier_fournisseur(req.code, req.fournisseur_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification fournisseur: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification fournisseur: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/fournisseurs/get", dependencies=[Depends(verify_token)])
def fournisseur_get(req: CodeRequest):
"""
NOUVEAU : Lecture d'un fournisseur par code
"""
try:
fournisseur = sage.lire_fournisseur(req.code)
if not fournisseur:
raise HTTPException(404, f"Fournisseur {req.code} non trouvé")
return {"success": True, "data": fournisseur}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture fournisseur: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/avoirs/list", dependencies=[Depends(verify_token)])
def avoirs_list(
limit: int = Query(100, description="Nombre max d'avoirs"),
statut: Optional[int] = Query(None, description="Filtrer par statut"),
filtre: str = Query("", description="Filtre texte"),
):
try:
avoirs = sage.lister_tous_avoirs_cache(filtre)
if statut is not None:
avoirs = [a for a in avoirs if a.get("statut") == statut]
avoirs = avoirs[:limit]
logger.info(f" {len(avoirs)} avoirs retournés depuis le cache")
return {"success": True, "data": avoirs}
except Exception as e:
logger.error(f" Erreur liste avoirs: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/avoirs/get", dependencies=[Depends(verify_token)])
def avoir_get(req: CodeRequest):
try:
avoir = sage.lire_avoir_cache(req.code)
if avoir:
logger.info(f" Avoir {req.code} retourné depuis le cache")
return {"success": True, "data": avoir, "source": "cache"}
logger.info(f"⚠️ Avoir {req.code} absent du cache, lecture depuis Sage...")
avoir = sage.lire_avoir(req.code)
if not avoir:
raise HTTPException(404, f"Avoir {req.code} non trouvé")
return {"success": True, "data": avoir, "source": "sage"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture avoir: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/livraisons/list", dependencies=[Depends(verify_token)])
def livraisons_list(
limit: int = Query(100, description="Nombre max de livraisons"),
statut: Optional[int] = Query(None, description="Filtrer par statut"),
filtre: str = Query("", description="Filtre texte"),
):
try:
livraisons = sage.lister_toutes_livraisons_cache(filtre)
if statut is not None:
livraisons = [l for l in livraisons if l.get("statut") == statut]
livraisons = livraisons[:limit]
logger.info(f" {len(livraisons)} livraisons retournées depuis le cache")
return {"success": True, "data": livraisons}
except Exception as e:
logger.error(f" Erreur liste livraisons: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/livraisons/get", dependencies=[Depends(verify_token)])
def livraison_get(req: CodeRequest):
try:
livraison = sage.lire_livraison_cache(req.code)
if livraison:
logger.info(f" Livraison {req.code} retournée depuis le cache")
return {"success": True, "data": livraison, "source": "cache"}
logger.info(f"⚠️ Livraison {req.code} absente du cache, lecture depuis Sage...")
livraison = sage.lire_livraison(req.code)
if not livraison:
raise HTTPException(404, f"Livraison {req.code} non trouvée")
return {"success": True, "data": livraison, "source": "sage"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture livraison: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/devis/update", dependencies=[Depends(verify_token)])
def modifier_devis_endpoint(req: DevisUpdateGatewayRequest):
try:
resultat = sage.modifier_devis(req.numero, req.devis_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification devis: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification devis: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/commandes/create", dependencies=[Depends(verify_token)])
def creer_commande_endpoint(req: CommandeCreateRequest):
try:
commande_data = {
"client": {"code": req.client_id, "intitule": ""},
"date_commande": req.date_commande or date.today(),
"date_livraison": req.date_livraison or date.today(),
"reference": req.reference,
"lignes": req.lignes,
}
resultat = sage.creer_commande_enrichi(commande_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier création commande: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur technique création commande: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/commandes/update", dependencies=[Depends(verify_token)])
def modifier_commande_endpoint(req: CommandeUpdateGatewayRequest):
try:
resultat = sage.modifier_commande(req.numero, req.commande_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification commande: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification commande: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/livraisons/create", dependencies=[Depends(verify_token)])
def creer_livraison_endpoint(req: LivraisonCreateGatewayRequest):
try:
client = sage.lire_client(req.client_id)
if not client:
raise HTTPException(404, f"Client {req.client_id} introuvable")
livraison_data = {
"client": {"code": req.client_id, "intitule": ""},
"date_livraison": req.date_livraison or date.today(),
"date_livraison_prevue": req.date_livraison or date.today(),
"reference": req.reference,
"lignes": req.lignes,
}
resultat = sage.creer_livraison_enrichi(livraison_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier création livraison: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur technique création livraison: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/livraisons/update", dependencies=[Depends(verify_token)])
def modifier_livraison_endpoint(req: LivraisonUpdateGatewayRequest):
try:
resultat = sage.modifier_livraison(req.numero, req.livraison_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification livraison: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification livraison: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/avoirs/create", dependencies=[Depends(verify_token)])
def creer_avoir_endpoint(req: AvoirCreateGatewayRequest):
try:
client = sage.lire_client(req.client_id)
if not client:
raise HTTPException(404, f"Client {req.client_id} introuvable")
avoir_data = {
"client": {"code": req.client_id, "intitule": ""},
"date_avoir": req.date_avoir or date.today(),
"date_livraison": req.date_livraison or date.today(),
"reference": req.reference,
"lignes": req.lignes,
}
resultat = sage.creer_avoir_enrichi(avoir_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier création avoir: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur technique création avoir: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/avoirs/update", dependencies=[Depends(verify_token)])
def modifier_avoir_endpoint(req: AvoirUpdateGatewayRequest):
"""
✏️ Modification d'un avoir dans Sage
"""
try:
resultat = sage.modifier_avoir(req.numero, req.avoir_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification avoir: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification avoir: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/factures/create", dependencies=[Depends(verify_token)])
def creer_facture_endpoint(req: FactureCreateGatewayRequest):
try:
client = sage.lire_client(req.client_id)
if not client:
raise HTTPException(404, f"Client {req.client_id} introuvable")
facture_data = {
"client": {"code": req.client_id, "intitule": ""},
"date_facture": req.date_facture or date.today(),
"date_livraison": req.date_livraison or date.today(),
"reference": req.reference,
"lignes": req.lignes,
}
resultat = sage.creer_facture_enrichi(facture_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier création facture: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur technique création facture: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/factures/update", dependencies=[Depends(verify_token)])
def modifier_facture_endpoint(req: FactureUpdateGatewayRequest):
try:
resultat = sage.modifier_facture(req.numero, req.facture_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification facture: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification facture: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/articles/create", dependencies=[Depends(verify_token)])
def create_article_endpoint(req: ArticleCreateRequest):
try:
resultat = sage.creer_article(req.dict())
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier création article: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur technique création article: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/articles/update", dependencies=[Depends(verify_token)])
def modifier_article_endpoint(req: ArticleUpdateGatewayRequest):
try:
resultat = sage.modifier_article(req.reference, req.article_data)
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"Erreur métier modification article: {e}")
raise HTTPException(404, str(e))
except Exception as e:
logger.error(f"Erreur technique modification article: {e}")
raise HTTPException(500, str(e))
@app.post(
"/sage/familles/create",
response_model=dict,
)
async def creer_famille(famille: FamilleCreate):
"""Crée une famille d'articles dans Sage 100c"""
try:
resultat = sage.creer_famille(famille.dict())
return {
"success": True,
"message": f"Famille {resultat['code']} créée avec succès",
"data": resultat,
}
except ValueError as e:
logger.warning(f"Erreur métier création famille : {e}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Erreur création famille : {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}")
@app.get(
"/sage/familles",
response_model=dict,
)
async def lister_familles(filtre: str = ""):
try:
familles = sage.lister_toutes_familles(filtre=filtre)
return {
"success": True,
"count": len(familles),
"filtre": filtre if filtre else None,
"data": familles,
"meta": {
"methode": "SQL direct (F_FAMILLE)",
"temps_reponse": "< 1 seconde",
},
}
except Exception as e:
logger.error(f"Erreur listage familles : {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}")
@app.get(
"/sage/familles/{code}",
response_model=dict,
)
async def lire_famille(code: str):
try:
familles = sage.lister_toutes_familles()
famille = next((f for f in familles if f["code"].upper() == code.upper()), None)
if not famille:
raise HTTPException(status_code=404, detail=f"Famille {code} introuvable")
return {"success": True, "data": famille}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture famille {code} : {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}")
@app.get("/sage/familles/stats", response_model=dict)
async def stats_familles():
try:
familles = sage.lister_toutes_familles()
nb_total = len(familles)
nb_detail = sum(1 for f in familles if f["type"] == 0)
nb_total_type = sum(1 for f in familles if f["type"] == 1)
nb_statistiques = sum(1 for f in familles if f["est_statistique"])
top_familles = sorted(familles, key=lambda f: f["intitule"])[:10]
return {
"success": True,
"stats": {
"total": nb_total,
"detail": nb_detail,
"total_type": nb_total_type,
"statistiques": nb_statistiques,
"pourcentage_detail": (
round((nb_detail / nb_total * 100), 2) if nb_total > 0 else 0
),
},
"top_10": [
{
"code": f["code"],
"intitule": f["intitule"],
"type_libelle": f["type_libelle"],
}
for f in top_familles
],
}
except Exception as e:
logger.error(f"Erreur stats familles : {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur serveur : {str(e)}")
@app.post("/sage/documents/generate-pdf", dependencies=[Depends(verify_token)])
def generer_pdf_document(req: PDFGenerationRequest):
try:
logger.info(f" Génération PDF: {req.doc_id} (type={req.type_doc})")
pdf_bytes = sage.generer_pdf_document(req.doc_id, req.type_doc)
if not pdf_bytes:
raise HTTPException(500, "PDF vide généré")
import base64
pdf_base64 = base64.b64encode(pdf_bytes).decode("utf-8")
logger.info(f" PDF généré: {len(pdf_bytes)} octets")
return {
"success": True,
"data": {
"pdf_base64": pdf_base64,
"taille_octets": len(pdf_bytes),
"type_doc": req.type_doc,
"numero": req.doc_id,
},
}
except HTTPException:
raise
except Exception as e:
logger.error(f" Erreur génération PDF: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.get("/sage/depots/list", dependencies=[Depends(verify_token)])
def lister_depots():
try:
if not sage or not sage.cial:
raise HTTPException(503, "Service Sage indisponible")
with sage._com_context(), sage._lock_com:
depots = []
try:
factory_depot = sage.cial.FactoryDepot
index = 1
while index <= 100: # Max 100 dépôts
try:
persist = factory_depot.List(index)
if persist is None:
logger.info(f" Fin de liste à l'index {index} (None)")
break
depot = win32com.client.CastTo(persist, "IBODepot3")
depot.Read()
code = ""
numero = 0
intitule = ""
contact = ""
exclu = False
try:
code = getattr(depot, "DE_Code", "").strip()
except:
pass
try:
numero = int(getattr(depot, "Compteur", 0))
except:
try:
numero = int(code)
except:
numero = 0
try:
intitule = getattr(depot, "DE_Intitule", "")
except:
pass
try:
contact = getattr(depot, "DE_Contact", "")
except:
pass
try:
exclu = getattr(depot, "DE_Exclure", False)
except:
pass
if not code:
logger.warning(f" ⚠️ Dépôt à l'index {index} sans code")
index += 1
continue
adresse_complete = ""
try:
adresse_obj = getattr(depot, "Adresse", None)
if adresse_obj:
try:
adresse = getattr(adresse_obj, "Adresse", "")
cp = getattr(adresse_obj, "CodePostal", "")
ville = getattr(adresse_obj, "Ville", "")
adresse_complete = f"{adresse} {cp} {ville}".strip()
except:
pass
except:
pass
principal = False
if not exclu and len(depots) == 0:
principal = True
depot_info = {
"code": code, # ⭐ "01", "02"
"numero": numero, # ⭐ 1, 2 (depuis Compteur)
"intitule": intitule,
"adresse": adresse_complete,
"contact": contact,
"exclu": exclu,
"principal": principal,
"index_sage": index,
}
depots.append(depot_info)
logger.info(
f" Dépôt {index}: code='{code}', compteur={numero}, intitulé='{intitule}'"
)
index += 1
except Exception as e:
error_msg = str(e)
if "Accès refusé" in error_msg or "-1073741819" in error_msg:
logger.info(
f" Fin de liste à l'index {index} (Accès refusé)"
)
break
else:
logger.error(f" Erreur inattendue index {index}: {e}")
index += 1
continue
logger.info(f" {len(depots)} dépôt(s) trouvé(s)")
if not depots:
return {
"success": False,
"depots": [],
"message": "Aucun dépôt trouvé dans Sage",
}
return {
"success": True,
"depots": depots,
"nb_depots": len(depots),
"version_sage": {
"identifiant_code": "DE_Code (string)",
"identifiant_numero": "Compteur (int)",
"fin_liste": "Erreur 'Accès refusé' au lieu de None",
},
"conseil": f"Utilisez le 'code' (ex: '{depots[0]['code']}') lors de la création d'articles avec stock",
}
except Exception as e:
logger.error(f" Erreur lecture dépôts: {e}", exc_info=True)
raise HTTPException(500, f"Erreur lecture dépôts: {str(e)}")
except HTTPException:
raise
except Exception as e:
logger.error(f" Erreur: {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/stock/entree", dependencies=[Depends(verify_token)])
def creer_entree_stock(req: EntreeStockRequest):
try:
logger.info(
f"📦 [ENTREE STOCK] Création bon d'entrée : {len(req.lignes)} ligne(s)"
)
entree_data = {
"date_mouvement": req.date_entree or date.today(),
"reference": req.reference,
"depot_code": req.depot_code,
"lignes": [ligne.dict() for ligne in req.lignes],
"commentaire": req.commentaire,
}
resultat = sage.creer_entree_stock(entree_data)
logger.info(f" [ENTREE STOCK] Créé : {resultat.get('numero')}")
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"⚠️ Erreur métier entrée stock : {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f" Erreur technique entrée stock : {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.post("/sage/stock/sortie", dependencies=[Depends(verify_token)])
def creer_sortie_stock(req: SortieStockRequest):
try:
logger.info(
f"📤 [SORTIE STOCK] Création bon de sortie : {len(req.lignes)} ligne(s)"
)
sortie_data = {
"date_mouvement": req.date_sortie or date.today(),
"reference": req.reference,
"depot_code": req.depot_code,
"lignes": [ligne.dict() for ligne in req.lignes],
"commentaire": req.commentaire,
}
resultat = sage.creer_sortie_stock(sortie_data)
logger.info(f" [SORTIE STOCK] Créé : {resultat.get('numero')}")
return {"success": True, "data": resultat}
except ValueError as e:
logger.warning(f"⚠️ Erreur métier sortie stock : {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f" Erreur technique sortie stock : {e}", exc_info=True)
raise HTTPException(500, str(e))
@app.get("/sage/stock/mouvement/{numero}", dependencies=[Depends(verify_token)])
def lire_mouvement_stock(numero: str):
try:
mouvement = sage.lire_mouvement_stock(numero)
if not mouvement:
raise HTTPException(404, f"Mouvement de stock {numero} non trouvé")
return {"success": True, "data": mouvement}
except HTTPException:
raise
except Exception as e:
logger.error(f" Erreur lecture mouvement : {e}")
raise HTTPException(500, str(e))
@app.post("/sage/contacts/create", dependencies=[Depends(verify_token)])
def contacts_create(req: ContactCreateRequest):
"""Crée un nouveau contact"""
try:
contact = sage.creer_contact(req.dict())
return {"success": True, "data": contact}
except ValueError as e:
logger.error(f"Erreur validation contact: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur création contact: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/contacts/list", dependencies=[Depends(verify_token)])
def contacts_list(req: ContactListRequest):
"""Liste les contacts d'un client"""
try:
contacts = sage.lister_contacts(req.numero)
return {"success": True, "data": contacts}
except Exception as e:
logger.error(f"Erreur liste contacts: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/contacts/get", dependencies=[Depends(verify_token)])
def contacts_get(req: ContactGetRequest):
"""Récupère un contact spécifique"""
try:
contact = sage.obtenir_contact(req.numero, req.contact_numero)
if not contact:
raise HTTPException(404, f"Contact {req.contact_numero} non trouvé")
return {"success": True, "data": contact}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur récupération contact: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/contacts/update", dependencies=[Depends(verify_token)])
def contacts_update(req: ContactUpdateRequest):
"""Modifie un contact existant"""
try:
contact = sage.modifier_contact(req.numero, req.contact_numero, req.updates)
return {"success": True, "data": contact}
except ValueError as e:
logger.error(f"Erreur validation contact: {e}")
raise HTTPException(400, str(e))
except Exception as e:
logger.error(f"Erreur modification contact: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/contacts/delete", dependencies=[Depends(verify_token)])
def contacts_delete(req: ContactDeleteRequest):
"""Supprime un contact"""
try:
result = sage.supprimer_contact(req.numero, req.contact_numero)
return {"success": True, "data": result}
except Exception as e:
logger.error(f"Erreur suppression contact: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/contacts/set-default", dependencies=[Depends(verify_token)])
def contacts_set_default(req: ContactGetRequest):
"""Définit un contact comme contact par défaut"""
try:
result = sage.definir_contact_defaut(req.numero, req.contact_numero)
return {"success": True, "data": result}
except Exception as e:
logger.error(f"Erreur définition contact par défaut: {e}")
raise HTTPException(500, str(e))
if __name__ == "__main__":
uvicorn.run(
"main:app",
host=settings.api_host,
port=settings.api_port,
reload=False, # Pas de reload en production
log_level="info",
)