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", )