2139 lines
87 KiB
Python
2139 lines
87 KiB
Python
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
|
||
# =====================================================
|
||
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__)
|
||
|
||
|
||
# =====================================================
|
||
# ENUMS
|
||
# =====================================================
|
||
class TypeDocument(int, Enum):
|
||
DEVIS = 0
|
||
BON_LIVRAISON = 1
|
||
BON_RETOUR = 2
|
||
COMMANDE = 3
|
||
PREPARATION = 4
|
||
FACTURE = 5
|
||
|
||
|
||
# =====================================================
|
||
# MODÈLES
|
||
# =====================================================
|
||
|
||
|
||
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):
|
||
"""
|
||
Modèle complet pour la création d'un client Sage 100c
|
||
Noms alignés sur le frontend + mapping vers champs Sage
|
||
"""
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# IDENTIFICATION PRINCIPALE
|
||
# ══════════════════════════════════════════════════════════════
|
||
intitule: str = Field(..., max_length=69, description="Nom du client (CT_Intitule)")
|
||
numero: Optional[str] = Field(None, max_length=17, description="Numéro client CT_Num (auto si vide)")
|
||
type_tiers: Optional[int] = Field(0, ge=0, le=3, description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre")
|
||
qualite: str = Field(None, 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")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# STATUTS & FLAGS
|
||
# ══════════════════════════════════════════════════════════════
|
||
est_prospect: bool = Field(False, description="CT_Prospect")
|
||
est_actif: bool = Field(True, description="Inverse de CT_Sommeil")
|
||
est_en_sommeil: Optional[bool] = Field(None, description="CT_Sommeil (calculé depuis est_actif si None)")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# INFORMATIONS ENTREPRISE / PERSONNE
|
||
# ══════════════════════════════════════════════════════════════
|
||
est_entreprise: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct")
|
||
est_particulier: Optional[bool] = Field(None, description="Flag interne - pas de champ Sage direct")
|
||
forme_juridique: Optional[str] = Field(None, max_length=33, description="CT_SvFormeJuri")
|
||
civilite: Optional[str] = Field(None, max_length=17, description="Stocké dans CT_Qualite ou champ libre")
|
||
nom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule")
|
||
prenom: Optional[str] = Field(None, max_length=35, description="Pour particuliers - partie de CT_Intitule")
|
||
nom_complet: Optional[str] = Field(None, max_length=69, description="Calculé ou CT_Intitule")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# ADRESSE PRINCIPALE
|
||
# ══════════════════════════════════════════════════════════════
|
||
contact: Optional[str] = Field(None, max_length=35, description="CT_Contact")
|
||
adresse: Optional[str] = Field(None, max_length=35, description="CT_Adresse")
|
||
complement: Optional[str] = Field(None, max_length=35, description="CT_Complement")
|
||
code_postal: Optional[str] = Field(None, max_length=9, description="CT_CodePostal")
|
||
ville: Optional[str] = Field(None, max_length=35, description="CT_Ville")
|
||
region: Optional[str] = Field(None, max_length=25, description="CT_CodeRegion")
|
||
pays: Optional[str] = Field(None, max_length=35, description="CT_Pays")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# CONTACT & COMMUNICATION
|
||
# ══════════════════════════════════════════════════════════════
|
||
telephone: Optional[str] = Field(None, max_length=21, description="CT_Telephone")
|
||
portable: Optional[str] = Field(None, max_length=21, description="Stocké dans statistiques ou contact")
|
||
telecopie: Optional[str] = Field(None, max_length=21, description="CT_Telecopie")
|
||
email: Optional[str] = Field(None, max_length=69, description="CT_EMail")
|
||
site_web: Optional[str] = Field(None, max_length=69, description="CT_Site")
|
||
facebook: Optional[str] = Field(None, max_length=35, description="CT_Facebook")
|
||
linkedin: Optional[str] = Field(None, max_length=35, description="CT_LinkedIn")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# IDENTIFIANTS LÉGAUX & FISCAUX
|
||
# ══════════════════════════════════════════════════════════════
|
||
siret: Optional[str] = Field(None, max_length=15, description="CT_Siret (14-15 chars)")
|
||
siren: Optional[str] = Field(None, max_length=9, description="Extrait du SIRET")
|
||
tva_intra: Optional[str] = Field(None, max_length=25, description="CT_Identifiant")
|
||
code_naf: Optional[str] = Field(None, max_length=7, description="CT_Ape")
|
||
type_nif: Optional[int] = Field(None, ge=0, le=10, description="CT_TypeNIF")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# BANQUE & DEVISE
|
||
# ══════════════════════════════════════════════════════════════
|
||
banque_num: Optional[int] = Field(None, description="BT_Num (smallint)")
|
||
devise: Optional[int] = Field(0, description="N_Devise (0=EUR)")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# CATÉGORIES & CLASSIFICATIONS COMMERCIALES
|
||
# ══════════════════════════════════════════════════════════════
|
||
categorie_tarifaire: Optional[int] = Field(1, ge=0, description="N_CatTarif")
|
||
categorie_comptable: Optional[int] = Field(1, ge=0, description="N_CatCompta")
|
||
periode_reglement: Optional[int] = Field(1, ge=0, description="N_Period")
|
||
mode_expedition: Optional[int] = Field(1, ge=0, description="N_Expedition")
|
||
condition_livraison: Optional[int] = Field(1, ge=0, description="N_Condition")
|
||
niveau_risque: Optional[int] = Field(1, ge=0, description="N_Risque")
|
||
secteur: Optional[str] = Field(None, max_length=21, description="CT_Statistique01 ou champ libre")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# TAUX PERSONNALISÉS
|
||
# ══════════════════════════════════════════════════════════════
|
||
taux01: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux01")
|
||
taux02: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux02")
|
||
taux03: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux03")
|
||
taux04: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Taux04")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# GESTION COMMERCIALE
|
||
# ══════════════════════════════════════════════════════════════
|
||
encours_autorise: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Encours")
|
||
assurance_credit: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_Assurance")
|
||
num_payeur: Optional[str] = Field(None, max_length=17, description="CT_NumPayeur")
|
||
langue: Optional[int] = Field(None, ge=0, description="CT_Langue")
|
||
langue_iso2: Optional[str] = Field(None, max_length=3, description="CT_LangueISO2")
|
||
commercial_code: Optional[int] = Field(None, description="CO_No (int)")
|
||
commercial_nom: Optional[str] = Field(None, description="Résolu depuis CO_No - non stocké")
|
||
effectif: Optional[str] = Field(None, max_length=11, description="CT_SvEffectif")
|
||
ca_annuel: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# COMPTABILITÉ
|
||
# ══════════════════════════════════════════════════════════════
|
||
compte_general: Optional[str] = Field("411000", max_length=13, description="CG_NumPrinc")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# PARAMÈTRES FACTURATION
|
||
# ══════════════════════════════════════════════════════════════
|
||
type_facture: Optional[int] = Field(1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée")
|
||
bl_en_facture: Optional[int] = Field(None, ge=0, le=1, description="CT_BLFact")
|
||
saut_page: Optional[int] = Field(None, ge=0, le=1, description="CT_Saut")
|
||
lettrage_auto: Optional[bool] = Field(True, description="CT_Lettrage")
|
||
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")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# LIVRAISON & LOGISTIQUE
|
||
# ══════════════════════════════════════════════════════════════
|
||
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)")
|
||
|
||
# JOURS DE COMMANDE (0=non, 1=oui) - CT_OrderDay01-07
|
||
jours_commande: Optional[dict] = Field(
|
||
None,
|
||
description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche"
|
||
)
|
||
|
||
# JOURS DE LIVRAISON (0=non, 1=oui) - CT_DeliveryDay01-07
|
||
jours_livraison: Optional[dict] = Field(
|
||
None,
|
||
description="Dict avec clés: lundi, mardi, mercredi, jeudi, vendredi, samedi, dimanche"
|
||
)
|
||
|
||
# DATES FERMETURE
|
||
date_fermeture_debut: Optional[date] = Field(None, description="CT_DateFermeDebut")
|
||
date_fermeture_fin: Optional[date] = Field(None, description="CT_DateFermeFin")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# STATISTIQUES PERSONNALISÉES (10 champs de 21 chars max)
|
||
# ══════════════════════════════════════════════════════════════
|
||
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")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# COMMENTAIRE
|
||
# ══════════════════════════════════════════════════════════════
|
||
commentaire: Optional[str] = Field(None, max_length=35, description="CT_Commentaire")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# ANALYTIQUE
|
||
# ══════════════════════════════════════════════════════════════
|
||
section_analytique: Optional[str] = Field(None, max_length=13, description="CA_Num")
|
||
section_analytique_ifrs: Optional[str] = Field(None, max_length=13, description="CA_NumIFRS")
|
||
plan_analytique: Optional[int] = Field(None, ge=0, description="N_Analytique")
|
||
plan_analytique_ifrs: Optional[int] = Field(None, ge=0, description="N_AnalytiqueIFRS")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# ORGANISATION
|
||
# ══════════════════════════════════════════════════════════════
|
||
depot_code: Optional[int] = Field(None, description="DE_No (int)")
|
||
etablissement_code: Optional[int] = Field(None, description="EB_No (int)")
|
||
mode_reglement_code: Optional[int] = Field(None, description="MR_No (int)")
|
||
calendrier_code: Optional[int] = Field(None, description="CAL_No (int)")
|
||
num_centrale: Optional[str] = Field(None, max_length=17, description="CT_NumCentrale")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# SURVEILLANCE COFACE
|
||
# ══════════════════════════════════════════════════════════════
|
||
coface: Optional[str] = Field(None, max_length=25, description="CT_Coface")
|
||
surveillance_active: Optional[int] = Field(None, ge=0, le=1, description="CT_Surveillance")
|
||
sv_date_creation: Optional[datetime] = Field(None, description="CT_SvDateCreate")
|
||
sv_chiffre_affaires: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvCA")
|
||
sv_resultat: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="CT_SvResultat")
|
||
sv_incident: Optional[int] = Field(None, ge=0, le=1, description="CT_SvIncident")
|
||
sv_date_incident: Optional[datetime] = Field(None, description="CT_SvDateIncid")
|
||
sv_privilege: Optional[int] = Field(None, ge=0, le=1, description="CT_SvPrivil")
|
||
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_date_maj: Optional[datetime] = Field(None, description="CT_SvDateMaj")
|
||
sv_objet_maj: Optional[str] = Field(None, max_length=61, description="CT_SvObjetMaj")
|
||
sv_date_bilan: Optional[datetime] = Field(None, description="CT_SvDateBilan")
|
||
sv_nb_mois_bilan: Optional[int] = Field(None, ge=0, description="CT_SvNbMoisBilan")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# FACTURATION ÉLECTRONIQUE
|
||
# ══════════════════════════════════════════════════════════════
|
||
facture_electronique: Optional[int] = Field(None, ge=0, le=1, description="CT_FactureElec")
|
||
edi_code_type: Optional[int] = Field(None, description="CT_EdiCodeType")
|
||
edi_code: Optional[str] = Field(None, max_length=23, description="CT_EdiCode")
|
||
edi_code_sage: Optional[str] = Field(None, max_length=9, description="CT_EdiCodeSage")
|
||
fe_assujetti: Optional[int] = Field(None, description="CT_FEAssujetti")
|
||
fe_autre_identif_type: Optional[int] = Field(None, description="CT_FEAutreIdentifType")
|
||
fe_autre_identif_val: Optional[str] = Field(None, max_length=81, description="CT_FEAutreIdentifVal")
|
||
fe_entite_type: Optional[int] = Field(None, description="CT_FEEntiteType")
|
||
fe_emission: Optional[int] = Field(None, description="CT_FEEmission")
|
||
fe_application: Optional[int] = Field(None, description="CT_FEApplication")
|
||
fe_date_synchro: Optional[datetime] = Field(None, description="CT_FEDateSynchro")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# ÉCHANGES & INTÉGRATION
|
||
# ══════════════════════════════════════════════════════════════
|
||
echange_rappro: Optional[int] = Field(None, description="CT_EchangeRappro")
|
||
echange_cr: Optional[int] = Field(None, description="CT_EchangeCR")
|
||
pi_no_echange: Optional[int] = Field(None, description="PI_NoEchange")
|
||
annulation_cr: Optional[int] = Field(None, description="CT_AnnulationCR")
|
||
profil_societe: Optional[int] = Field(None, description="CT_ProfilSoc")
|
||
statut_contrat: Optional[int] = Field(None, description="CT_StatutContrat")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# RGPD & CONFIDENTIALITÉ
|
||
# ══════════════════════════════════════════════════════════════
|
||
rgpd_consentement: Optional[int] = Field(None, ge=0, le=1, description="CT_GDPR")
|
||
exclure_traitement: Optional[int] = Field(None, ge=0, le=1, description="CT_ExclureTrait")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# REPRÉSENTANT FISCAL
|
||
# ══════════════════════════════════════════════════════════════
|
||
representant_intl: Optional[str] = Field(None, max_length=35, description="CT_RepresentInt")
|
||
representant_nif: Optional[str] = Field(None, max_length=25, description="CT_RepresentNIF")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# CHAMPS PERSONNALISÉS (Info Libres Sage)
|
||
# ══════════════════════════════════════════════════════════════
|
||
date_creation_societe: Optional[datetime] = Field(None, description="Date création société (Info Libre)")
|
||
capital_social: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6, description="Capital social")
|
||
actionnaire_principal: Optional[str] = Field(None, max_length=69, description="Actionnaire Pal")
|
||
score_banque_france: Optional[str] = Field(None, max_length=14, description="Score Banque de France")
|
||
|
||
# FIDÉLITÉ
|
||
total_points_fidelite: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6)
|
||
points_fidelite_restants: Optional[Decimal] = Field(None, max_digits=24, decimal_places=6)
|
||
date_fin_carte_fidelite: Optional[datetime] = Field(None, description="Fin validité carte fidélité")
|
||
date_negociation_reglement: Optional[datetime] = Field(None, description="Date négociation règlement")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# AUTRES
|
||
# ══════════════════════════════════════════════════════════════
|
||
mode_test: Optional[int] = Field(None, ge=0, le=1, description="CT_ModeTest")
|
||
confiance: Optional[int] = Field(None, description="CT_Confiance")
|
||
dn_id: Optional[str] = Field(None, max_length=37, description="DN_Id")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# MÉTADONNÉES (en lecture seule généralement)
|
||
# ══════════════════════════════════════════════════════════════
|
||
date_creation: Optional[datetime] = Field(None, description="cbCreation - géré par Sage")
|
||
date_modification: Optional[datetime] = Field(None, description="cbModification - géré par Sage")
|
||
date_maj: Optional[datetime] = Field(None, description="CT_DateMAJ")
|
||
|
||
# ══════════════════════════════════════════════════════════════
|
||
# VALIDATORS
|
||
# ══════════════════════════════════════════════════════════════
|
||
|
||
@field_validator('siret')
|
||
@classmethod
|
||
def validate_siret(cls, v):
|
||
if v and v.lower() not in ('none', ''):
|
||
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('siren')
|
||
@classmethod
|
||
def validate_siren(cls, v):
|
||
if v and v.lower() not in ('none', ''):
|
||
cleaned = v.replace(' ', '')
|
||
if len(cleaned) != 9:
|
||
raise ValueError('Le SIREN doit contenir 9 caractères')
|
||
return cleaned
|
||
return None
|
||
|
||
@field_validator('email')
|
||
@classmethod
|
||
def validate_email(cls, v):
|
||
if v and v.lower() not in ('none', ''):
|
||
if '@' not in v:
|
||
raise ValueError('Format email invalide')
|
||
return v.strip()
|
||
return None
|
||
|
||
@field_validator('adresse', 'code_postal', 'ville', 'pays', 'telephone', 'tva_intra', mode='before')
|
||
@classmethod
|
||
def clean_none_strings(cls, v):
|
||
"""Convertit les chaînes 'None' en None"""
|
||
if isinstance(v, str) and v.lower() in ('none', 'null', ''):
|
||
return None
|
||
return v
|
||
|
||
@field_validator('est_en_sommeil', mode='before')
|
||
@classmethod
|
||
def compute_sommeil(cls, v, info):
|
||
"""Calcule est_en_sommeil depuis est_actif si non fourni"""
|
||
if v is None and 'est_actif' in info.data:
|
||
return not info.data.get('est_actif', True)
|
||
return v
|
||
|
||
def to_sage_dict(self) -> dict:
|
||
"""Convertit le modèle en dictionnaire compatible avec la méthode creer_client"""
|
||
return {
|
||
# Identification
|
||
"intitule": self.intitule,
|
||
"num": self.numero,
|
||
"type_tiers": self.type_tiers,
|
||
"qualite": self.qualite,
|
||
"classement": self.classement,
|
||
"raccourci": self.raccourci,
|
||
|
||
# Statuts
|
||
"prospect": self.est_prospect,
|
||
"sommeil": not self.est_actif if self.est_en_sommeil is None else self.est_en_sommeil,
|
||
|
||
# Adresse
|
||
"contact": self.contact,
|
||
"adresse": self.adresse,
|
||
"complement": self.complement,
|
||
"code_postal": self.code_postal,
|
||
"ville": self.ville,
|
||
"code_region": self.region,
|
||
"pays": self.pays,
|
||
|
||
# Communication
|
||
"telephone": self.telephone,
|
||
"telecopie": self.telecopie,
|
||
"email": self.email,
|
||
"site": self.site_web,
|
||
"facebook": self.facebook,
|
||
"linkedin": self.linkedin,
|
||
|
||
# Identifiants légaux
|
||
"siret": self.siret,
|
||
"tva_intra": self.tva_intra,
|
||
"ape": self.code_naf,
|
||
"type_nif": self.type_nif,
|
||
|
||
# Banque & devise
|
||
"banque_num": self.banque_num,
|
||
"devise": self.devise,
|
||
|
||
# Catégories
|
||
"cat_tarif": self.categorie_tarifaire or 1,
|
||
"cat_compta": self.categorie_comptable or 1,
|
||
"period": self.periode_reglement or 1,
|
||
"expedition": self.mode_expedition or 1,
|
||
"condition": self.condition_livraison or 1,
|
||
"risque": self.niveau_risque or 1,
|
||
|
||
# Taux
|
||
"taux01": self.taux01,
|
||
"taux02": self.taux02,
|
||
"taux03": self.taux03,
|
||
"taux04": self.taux04,
|
||
|
||
# Gestion commerciale
|
||
"encours": self.encours_autorise,
|
||
"assurance": self.assurance_credit,
|
||
"num_payeur": self.num_payeur,
|
||
"langue": self.langue,
|
||
"langue_iso2": self.langue_iso2,
|
||
"compte_collectif": self.compte_general or "411000",
|
||
"collaborateur": self.commercial_code,
|
||
|
||
# Facturation
|
||
"facture": self.type_facture,
|
||
"bl_fact": self.bl_en_facture,
|
||
"saut": self.saut_page,
|
||
"lettrage": self.lettrage_auto,
|
||
"valid_ech": self.validation_echeance,
|
||
"control_enc": self.controle_encours,
|
||
"not_rappel": self.exclure_relance,
|
||
"not_penal": self.exclure_penalites,
|
||
"bon_a_payer": self.bon_a_payer,
|
||
|
||
# Livraison
|
||
"priorite_livr": self.priorite_livraison,
|
||
"livr_partielle": self.livraison_partielle,
|
||
"delai_transport": self.delai_transport,
|
||
"delai_appro": self.delai_appro,
|
||
"date_ferme_debut": self.date_fermeture_debut,
|
||
"date_ferme_fin": self.date_fermeture_fin,
|
||
|
||
# Jours commande/livraison
|
||
**(self._expand_jours("order_day", self.jours_commande) if self.jours_commande else {}),
|
||
**(self._expand_jours("delivery_day", self.jours_livraison) if self.jours_livraison else {}),
|
||
|
||
# Statistiques
|
||
"statistique01": self.statistique01 or self.secteur,
|
||
"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,
|
||
|
||
# Commentaire
|
||
"commentaire": self.commentaire,
|
||
|
||
# Analytique
|
||
"section_analytique": self.section_analytique,
|
||
"section_analytique_ifrs": self.section_analytique_ifrs,
|
||
"plan_analytique": self.plan_analytique,
|
||
"plan_analytique_ifrs": self.plan_analytique_ifrs,
|
||
|
||
# Organisation
|
||
"depot": self.depot_code,
|
||
"etablissement": self.etablissement_code,
|
||
"mode_regl": self.mode_reglement_code,
|
||
"calendrier": self.calendrier_code,
|
||
"num_centrale": self.num_centrale,
|
||
|
||
# Surveillance
|
||
"coface": self.coface,
|
||
"surveillance": self.surveillance_active,
|
||
"sv_forme_juri": self.forme_juridique,
|
||
"sv_effectif": self.effectif,
|
||
"sv_ca": self.sv_chiffre_affaires or self.ca_annuel,
|
||
"sv_resultat": self.sv_resultat,
|
||
"sv_incident": self.sv_incident,
|
||
"sv_date_incid": self.sv_date_incident,
|
||
"sv_privil": self.sv_privilege,
|
||
"sv_regul": self.sv_regularite,
|
||
"sv_cotation": self.sv_cotation,
|
||
"sv_date_create": self.sv_date_creation,
|
||
"sv_date_maj": self.sv_date_maj,
|
||
"sv_objet_maj": self.sv_objet_maj,
|
||
"sv_date_bilan": self.sv_date_bilan,
|
||
"sv_nb_mois_bilan": self.sv_nb_mois_bilan,
|
||
|
||
# Facturation électronique
|
||
"facture_elec": self.facture_electronique,
|
||
"edi_code_type": self.edi_code_type,
|
||
"edi_code": self.edi_code,
|
||
"edi_code_sage": self.edi_code_sage,
|
||
"fe_assujetti": self.fe_assujetti,
|
||
"fe_autre_identif_type": self.fe_autre_identif_type,
|
||
"fe_autre_identif_val": self.fe_autre_identif_val,
|
||
"fe_entite_type": self.fe_entite_type,
|
||
"fe_emission": self.fe_emission,
|
||
"fe_application": self.fe_application,
|
||
|
||
# Échanges
|
||
"echange_rappro": self.echange_rappro,
|
||
"echange_cr": self.echange_cr,
|
||
"annulation_cr": self.annulation_cr,
|
||
"profil_soc": self.profil_societe,
|
||
"statut_contrat": self.statut_contrat,
|
||
|
||
# RGPD
|
||
"gdpr": self.rgpd_consentement,
|
||
"exclure_trait": self.exclure_traitement,
|
||
|
||
# Représentant
|
||
"represent_int": self.representant_intl,
|
||
"represent_nif": self.representant_nif,
|
||
|
||
# Autres
|
||
"mode_test": self.mode_test,
|
||
"confiance": self.confiance,
|
||
}
|
||
|
||
def _expand_jours(self, prefix: str, jours: dict) -> dict:
|
||
"""Expand les jours en champs individuels"""
|
||
mapping = {
|
||
"lundi": f"{prefix}_lundi",
|
||
"mardi": f"{prefix}_mardi",
|
||
"mercredi": f"{prefix}_mercredi",
|
||
"jeudi": f"{prefix}_jeudi",
|
||
"vendredi": f"{prefix}_vendredi",
|
||
"samedi": f"{prefix}_samedi",
|
||
"dimanche": f"{prefix}_dimanche",
|
||
}
|
||
return {v: jours.get(k) for k, v in mapping.items() if jours.get(k) is not None}
|
||
|
||
class Config:
|
||
json_schema_extra = {
|
||
"example": {
|
||
"intitule": "ENTREPRISE EXEMPLE SARL",
|
||
"numero": "CLI00123",
|
||
"compte_general": "411000",
|
||
"qualite": "CLI",
|
||
"est_prospect": False,
|
||
"est_actif": True
|
||
}
|
||
}
|
||
|
||
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)"
|
||
)
|
||
|
||
|
||
# =====================================================
|
||
# SÉCURITÉ
|
||
# =====================================================
|
||
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
|
||
|
||
|
||
# =====================================================
|
||
# APPLICATION
|
||
# =====================================================
|
||
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
|
||
|
||
|
||
# =====================================================
|
||
# LIFECYCLE
|
||
# =====================================================
|
||
@app.on_event("startup")
|
||
def startup():
|
||
global sage
|
||
|
||
logger.info("🚀 Démarrage Sage Gateway Windows...")
|
||
|
||
# Validation config
|
||
try:
|
||
validate_settings()
|
||
logger.info(" Configuration validée")
|
||
except ValueError as e:
|
||
logger.error(f" Configuration invalide: {e}")
|
||
raise
|
||
|
||
# Connexion Sage
|
||
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é")
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - SYSTÈME
|
||
# =====================================================
|
||
@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(),
|
||
}
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - CLIENTS
|
||
# =====================================================
|
||
@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))
|
||
|
||
|
||
# DANS main.py
|
||
@app.post("/sage/clients/create", dependencies=[Depends(verify_token)])
|
||
def create_client_endpoint(req: ClientCreateRequest):
|
||
"""Création d'un client dans Sage"""
|
||
try:
|
||
# L'appel au connecteur est fait ici
|
||
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}")
|
||
# Erreur métier (ex: doublon) -> 400 Bad Request
|
||
raise HTTPException(400, str(e))
|
||
except Exception as e:
|
||
logger.error(f"Erreur technique création client: {e}")
|
||
# Erreur technique (ex: COM) -> 500 Internal Server Error
|
||
raise HTTPException(500, str(e))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - ARTICLES
|
||
# =====================================================
|
||
@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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - DEVIS
|
||
# =====================================================
|
||
@app.post("/sage/devis/create", dependencies=[Depends(verify_token)])
|
||
def creer_devis(req: DevisRequest):
|
||
"""Création d'un devis"""
|
||
try:
|
||
# Transformer en format attendu par sage_connector
|
||
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:
|
||
# Lecture complète depuis Sage (avec lignes)
|
||
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:
|
||
# Récupération depuis le cache (instantané)
|
||
devis_list = sage.lister_tous_devis_cache(filtre)
|
||
|
||
# Filtrer par statut si demandé
|
||
if statut is not None:
|
||
devis_list = [d for d in devis_list if d.get("statut") == statut]
|
||
|
||
# Limiter le nombre de résultats
|
||
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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - DOCUMENTS
|
||
# =====================================================
|
||
@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}"
|
||
)
|
||
|
||
# Matrice des transformations valides pour VOTRE Sage
|
||
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}",
|
||
)
|
||
|
||
# Appel au connecteur Sage
|
||
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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - CONTACTS
|
||
# =====================================================
|
||
@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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - ADMIN
|
||
# =====================================================
|
||
@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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - PROSPECTS
|
||
# =====================================================
|
||
@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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - FOURNISSEURS
|
||
# =====================================================
|
||
@app.post("/sage/fournisseurs/list", dependencies=[Depends(verify_token)])
|
||
def fournisseurs_list(req: FiltreRequest):
|
||
try:
|
||
# Utiliser le cache au lieu de la lecture directe
|
||
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:
|
||
# Appel au connecteur Sage
|
||
resultat = sage.creer_fournisseur(req.dict())
|
||
|
||
logger.info(f" Fournisseur créé: {resultat.get('numero')}")
|
||
|
||
return {"success": True, "data": resultat}
|
||
|
||
except ValueError as e:
|
||
# Erreur métier (ex: doublon)
|
||
logger.warning(f"⚠️ Erreur métier création fournisseur: {e}")
|
||
raise HTTPException(400, str(e))
|
||
|
||
except Exception as e:
|
||
# Erreur technique (ex: COM)
|
||
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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - AVOIRS
|
||
# =====================================================
|
||
@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:
|
||
# Récupération depuis le cache (instantané)
|
||
avoirs = sage.lister_tous_avoirs_cache(filtre)
|
||
|
||
# Filtrer par statut si demandé
|
||
if statut is not None:
|
||
avoirs = [a for a in avoirs if a.get("statut") == statut]
|
||
|
||
# Limiter le nombre de résultats
|
||
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:
|
||
# Essayer le cache d'abord
|
||
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"}
|
||
|
||
# Pas dans le cache → Lecture directe depuis Sage
|
||
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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - LIVRAISONS
|
||
# =====================================================
|
||
@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:
|
||
# Récupération depuis le cache (instantané)
|
||
livraisons = sage.lister_toutes_livraisons_cache(filtre)
|
||
|
||
# Filtrer par statut si demandé
|
||
if statut is not None:
|
||
livraisons = [l for l in livraisons if l.get("statut") == statut]
|
||
|
||
# Limiter le nombre de résultats
|
||
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:
|
||
# Essayer le cache d'abord
|
||
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"}
|
||
|
||
# Pas dans le cache → Lecture directe depuis Sage
|
||
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))
|
||
|
||
|
||
# =====================================================
|
||
# ENDPOINTS - CRÉATION ET MODIFICATION COMMANDES
|
||
# =====================================================
|
||
|
||
|
||
@app.post("/sage/commandes/create", dependencies=[Depends(verify_token)])
|
||
def creer_commande_endpoint(req: CommandeCreateRequest):
|
||
try:
|
||
# Transformer en format attendu par sage_connector
|
||
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:
|
||
# Vérifier que le client existe
|
||
client = sage.lire_client(req.client_id)
|
||
if not client:
|
||
raise HTTPException(404, f"Client {req.client_id} introuvable")
|
||
|
||
# Préparer les données pour le connecteur
|
||
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:
|
||
# Vérifier que le client existe
|
||
client = sage.lire_client(req.client_id)
|
||
if not client:
|
||
raise HTTPException(404, f"Client {req.client_id} introuvable")
|
||
|
||
# Préparer les données pour le connecteur
|
||
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:
|
||
# Vérifier que le client existe
|
||
client = sage.lire_client(req.client_id)
|
||
if not client:
|
||
raise HTTPException(404, f"Client {req.client_id} introuvable")
|
||
|
||
# Préparer les données pour le connecteur
|
||
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)}")
|
||
|
||
|
||
# ========================================
|
||
# ROUTE GET : Lister toutes les familles
|
||
# ========================================
|
||
|
||
|
||
@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)}")
|
||
|
||
|
||
# ========================================
|
||
# ROUTE GET : Lire UNE famille par son code
|
||
# ========================================
|
||
|
||
|
||
@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)}")
|
||
|
||
|
||
# ========================================
|
||
# ROUTE GET : Statistiques sur les familles
|
||
# ========================================
|
||
|
||
|
||
@app.get("/sage/familles/stats", response_model=dict)
|
||
async def stats_familles():
|
||
try:
|
||
familles = sage.lister_toutes_familles()
|
||
|
||
# Calculer les stats
|
||
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 10 familles par intitulé (alphabétique)
|
||
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})")
|
||
|
||
# Appel au connecteur Sage
|
||
pdf_bytes = sage.generer_pdf_document(req.doc_id, req.type_doc)
|
||
|
||
if not pdf_bytes:
|
||
raise HTTPException(500, "PDF vide généré")
|
||
|
||
# Encoder en base64 pour le transport JSON
|
||
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()
|
||
|
||
# Lire les attributs identifiés
|
||
code = ""
|
||
numero = 0
|
||
intitule = ""
|
||
contact = ""
|
||
exclu = False
|
||
|
||
try:
|
||
code = getattr(depot, "DE_Code", "").strip()
|
||
except:
|
||
pass
|
||
|
||
try:
|
||
numero = int(getattr(depot, "Compteur", 0))
|
||
except:
|
||
# Fallback : convertir DE_Code en int
|
||
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
|
||
|
||
# Validation : un dépôt doit avoir au moins un code
|
||
if not code:
|
||
logger.warning(f" ⚠️ Dépôt à l'index {index} sans code")
|
||
index += 1
|
||
continue
|
||
|
||
# Récupérer adresse (objet COM complexe)
|
||
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
|
||
|
||
# Déterminer si principal (premier non exclu = principal)
|
||
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:
|
||
# ⚠️ CORRECTION : "Accès refusé" = fin de liste dans cette version Sage
|
||
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)"
|
||
)
|
||
|
||
# Préparer les données pour le connecteur
|
||
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,
|
||
}
|
||
|
||
# Appel au connecteur
|
||
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)"
|
||
)
|
||
|
||
# Préparer les données pour le connecteur
|
||
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,
|
||
}
|
||
|
||
# Appel au connecteur
|
||
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))
|
||
|
||
|
||
# =====================================================
|
||
# LANCEMENT
|
||
# =====================================================
|
||
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",
|
||
)
|