diff --git a/main.py b/main.py index 5e9d5c6..d4463fa 100644 --- a/main.py +++ b/main.py @@ -1443,15 +1443,12 @@ def regler_facture_endpoint( numero_facture: str, req: ReglementFactureRequest, ): - """ - Règle une facture (totalement ou partiellement) - - - **numero_facture**: Numéro de la facture (ex: FA00081) - - **montant**: Montant du règlement - - **mode_reglement**: 1=Virement, 2=Chèque, 3=Traite, 4=CB, 5=LCR, 6=Prélèvement, 7=Espèces - """ try: - date_reg = datetime.combine(req.date_reglement, datetime.min.time()) if req.date_reglement else None + date_reg = ( + datetime.combine(req.date_reglement, datetime.min.time()) + if req.date_reglement + else None + ) result = sage.regler_facture( numero_facture=numero_facture, @@ -1461,7 +1458,11 @@ def regler_facture_endpoint( reference=req.reference or "", libelle=req.libelle or "", ) - return {"success": True, "message": "Règlement effectué avec succès", "data": result} + return { + "success": True, + "message": "Règlement effectué avec succès", + "data": result, + } except ValueError as e: raise HTTPException(400, str(e)) @@ -1472,12 +1473,6 @@ def regler_facture_endpoint( @app.post("/sage/reglements/multiple", dependencies=[Depends(verify_token)]) def regler_factures_client_endpoint(req: ReglementMultipleRequest): - """ - Règle plusieurs factures d'un client - - Si numeros_factures est fourni, règle ces factures dans l'ordre. - Sinon, règle les factures les plus anciennes en priorité. - """ try: resultat = sage.regler_factures_client( client_code=req.client_code, @@ -1504,9 +1499,6 @@ def regler_factures_client_endpoint(req: ReglementMultipleRequest): "/sage/factures/{numero_facture}/reglements", dependencies=[Depends(verify_token)] ) def get_reglements_facture_endpoint(numero_facture: str): - """ - Récupère tous les règlements d'une facture - """ try: resultat = sage.lire_reglements_facture(numero_facture) return {"success": True, "data": resultat} @@ -1526,9 +1518,6 @@ def get_reglements_client_endpoint( date_fin: Optional[datetime] = Query(None, description="Date fin (filtrage)"), inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), ): - """ - Récupère tous les règlements d'un client avec leurs factures - """ try: resultat = sage.lire_reglements_client( client_code=client_code, @@ -1546,11 +1535,56 @@ def get_reglements_client_endpoint( raise HTTPException(500, str(e)) +@app.post( + "/sage/factures/{numero_facture}/valider", dependencies=[Depends(verify_token)] +) +def valider_facture_endpoint(numero_facture: str): + try: + resultat = sage.valider_facture(numero_facture) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier validation {numero_facture}: {e}") + raise HTTPException(400, str(e)) + except Exception as e: + logger.error(f"Erreur technique validation {numero_facture}: {e}") + raise HTTPException(500, str(e)) + + +@app.post( + "/sage/factures/{numero_facture}/devalider", dependencies=[Depends(verify_token)] +) +def devalider_facture_endpoint(numero_facture: str): + try: + resultat = sage.devalider_facture(numero_facture) + return {"success": True, "data": resultat} + + except ValueError as e: + logger.warning(f"Erreur métier dévalidation {numero_facture}: {e}") + raise HTTPException(400, str(e)) + except Exception as e: + logger.error(f"Erreur technique dévalidation {numero_facture}: {e}") + raise HTTPException(500, str(e)) + + +@app.get( + "/sage/factures/{numero_facture}/statut-validation", + dependencies=[Depends(verify_token)], +) +def get_statut_validation_endpoint(numero_facture: str): + try: + resultat = sage.get_statut_validation(numero_facture) + return {"success": True, "data": resultat} + + except ValueError as e: + raise HTTPException(404, str(e)) + except Exception as e: + logger.error(f"Erreur lecture statut {numero_facture}: {e}") + raise HTTPException(500, str(e)) + + @app.get("/sage/reglements/modes", dependencies=[Depends(verify_token)]) def get_modes_reglement(): - """ - Retourne la liste des modes de règlement disponibles - """ return { "success": True, "data": { @@ -1569,16 +1603,6 @@ def get_modes_reglement(): @app.get("/sage/journaux") def get_tous_journaux(): - """ - Liste TOUS les journaux (pour diagnostic) - - Types: - - 0 = Achats - - 1 = Ventes - - 2 = Trésorerie (Banque/Caisse) ← Pour les règlements - - 3 = Général (OD) - - 4 = Situation - """ try: journaux = sage.lire_tous_journaux() @@ -1606,9 +1630,6 @@ def get_tous_journaux(): @app.get("/sage/journaux/banque") def get_journaux_banque(): - """ - Liste les journaux de trésorerie (type 2) pour les règlements - """ try: journaux = sage.lire_journaux_banque() @@ -1631,11 +1652,6 @@ def get_journaux_banque(): @app.get("/sage/reglements/introspection") def introspecter_reglements(): - """ - Introspection des objets COM de règlement (diagnostic) - - Utile pour comprendre les attributs disponibles sur les objets de règlement. - """ try: data = sage.introspecter_reglement() return {"success": True, "data": data} @@ -1691,7 +1707,7 @@ def introspection_com(): try: val = getattr(param, logo_attr, None) resultats["logo_tests"][logo_attr] = str(type(val)) - except: + except Exception: pass except Exception as e: diff --git a/sage_connector.py b/sage_connector.py index 7ae4c3b..5ba597d 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -103,6 +103,12 @@ from utils.documents.settle import ( introspecter_reglement as _intro, ) +from utils.documents.validations import ( + valider_facture as _valider, + devalider_facture as _devalider, + get_statut_validation as _get_statut, +) + logger = logging.getLogger(__name__) @@ -3732,7 +3738,7 @@ class SageConnector: client.ReadLock() locked = True lock_method_used = "ReadLock" - logger.info(f" Verrouillage via ReadLock() [OK]") + logger.info(" Verrouillage via ReadLock() [OK]") break # Approche 2: Lock @@ -3740,7 +3746,7 @@ class SageConnector: client.Lock() locked = True lock_method_used = "Lock" - logger.info(f" Verrouillage via Lock() [OK]") + logger.info(" Verrouillage via Lock() [OK]") break # Approche 3: LockRecord @@ -3748,7 +3754,7 @@ class SageConnector: client.LockRecord() locked = True lock_method_used = "LockRecord" - logger.info(f" Verrouillage via LockRecord() [OK]") + logger.info(" Verrouillage via LockRecord() [OK]") break # Approche 4: Read avec paramètre mode écriture @@ -3756,12 +3762,12 @@ class SageConnector: try: client.Read(1) # 1 = mode écriture lock_method_used = "Read(1)" - logger.info(f" Verrouillage via Read(1) [OK]") + logger.info(" Verrouillage via Read(1) [OK]") except TypeError: client.Read() lock_method_used = "Read()" logger.info( - f" Read() simple (pas de verrouillage explicite)" + " Read() simple (pas de verrouillage explicite)" ) break @@ -8079,3 +8085,12 @@ class SageConnector: def introspecter_reglement(self): return _intro(self) + + def valider_facture(self, numero_facture: str): + return _valider(self, numero_facture) + + def devalider_facture(self, numero_facture: str): + return _devalider(self, numero_facture) + + def get_statut_validation(self, numero_facture: str): + return _get_statut(self, numero_facture) diff --git a/utils/documents/settle.py b/utils/documents/settle.py index 7525c91..242ca89 100644 --- a/utils/documents/settle.py +++ b/utils/documents/settle.py @@ -1,4 +1,4 @@ -from typing import Dict, List, Optional +from typing import Dict, List import win32com.client import pywintypes from datetime import datetime diff --git a/utils/documents/validations.py b/utils/documents/validations.py new file mode 100644 index 0000000..01e933f --- /dev/null +++ b/utils/documents/validations.py @@ -0,0 +1,267 @@ +from typing import Dict +import win32com.client +import logging + +logger = logging.getLogger(__name__) + + +def valider_facture(self, numero_facture: str) -> Dict: + """ + Valide une facture (pose le cadenas) + + Une facture validée ne peut plus être modifiée dans Sage. + DO_Valide passe de 0 à 1. + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + logger.info(f"🔒 Validation facture {numero_facture}") + + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + + if not factory.ExistPiece(60, numero_facture): + raise ValueError(f"Facture {numero_facture} introuvable") + + persist = factory.ReadPiece(60, numero_facture) + if not persist: + raise ValueError(f"Impossible de lire la facture {numero_facture}") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + valide_actuel = getattr(doc, "DO_Valide", 0) + statut = getattr(doc, "DO_Statut", 0) + + if statut == 6: + raise ValueError( + f"Facture {numero_facture} annulée, validation impossible" + ) + + if valide_actuel == 1: + logger.info(f"Facture {numero_facture} déjà validée") + return _build_response(doc, numero_facture, deja_valide=True) + + doc.DO_Valide = 1 + doc.Write() + doc.Read() + + nouveau_valide = getattr(doc, "DO_Valide", 0) + + if nouveau_valide != 1: + raise RuntimeError("Échec validation: DO_Valide non modifié") + + logger.info(f"✅ Facture {numero_facture} validée") + + return _build_response(doc, numero_facture, deja_valide=False) + + except ValueError: + raise + except Exception as e: + logger.error(f"❌ Erreur validation {numero_facture}: {e}", exc_info=True) + raise RuntimeError(f"Échec validation: {str(e)}") + + +def devalider_facture(self, numero_facture: str) -> Dict: + """ + Dévalide une facture (retire le cadenas) + + Attention: Une facture avec règlements ne peut pas être dévalidée. + DO_Valide passe de 1 à 0. + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + logger.info(f"🔓 Dévalidation facture {numero_facture}") + + try: + with self._com_context(), self._lock_com: + montant_regle = _get_montant_regle(self, numero_facture) + if montant_regle > 0.01: + raise ValueError( + f"Facture {numero_facture} partiellement réglée ({montant_regle}€), " + "dévalidation impossible" + ) + + factory = self.cial.FactoryDocumentVente + + if not factory.ExistPiece(60, numero_facture): + raise ValueError(f"Facture {numero_facture} introuvable") + + persist = factory.ReadPiece(60, numero_facture) + if not persist: + raise ValueError(f"Impossible de lire la facture {numero_facture}") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + valide_actuel = getattr(doc, "DO_Valide", 0) + statut = getattr(doc, "DO_Statut", 0) + + if statut == 6: + raise ValueError(f"Facture {numero_facture} annulée") + + if statut == 5: + raise ValueError( + f"Facture {numero_facture} transformée, dévalidation impossible" + ) + + if valide_actuel == 0: + logger.info(f"Facture {numero_facture} déjà non validée") + return _build_response( + doc, numero_facture, deja_valide=False, action="devalidation" + ) + + doc.DO_Valide = 0 + doc.Write() + doc.Read() + + nouveau_valide = getattr(doc, "DO_Valide", 0) + + if nouveau_valide != 0: + raise RuntimeError("Échec dévalidation: DO_Valide non modifié") + + logger.info(f"✅ Facture {numero_facture} dévalidée") + + return _build_response( + doc, numero_facture, deja_valide=False, action="devalidation" + ) + + except ValueError: + raise + except Exception as e: + logger.error(f"❌ Erreur dévalidation {numero_facture}: {e}", exc_info=True) + raise RuntimeError(f"Échec dévalidation: {str(e)}") + + +def get_statut_validation(self, numero_facture: str) -> Dict: + """ + Retourne le statut de validation d'une facture + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + with self._get_sql_connection() as conn: + cursor = conn.cursor() + + cursor.execute( + """ + SELECT + DO_Valide, DO_Statut, DO_TotalTTC, DO_MontantRegle, + CT_NumPayeur, DO_Date, DO_Ref + FROM F_DOCENTETE + WHERE DO_Piece = ? AND DO_Type = 6 + """, + (numero_facture,), + ) + row = cursor.fetchone() + + if not row: + raise ValueError(f"Facture {numero_facture} introuvable") + + valide = int(row[0]) if row[0] is not None else 0 + statut = int(row[1]) if row[1] is not None else 0 + total_ttc = float(row[2]) if row[2] else 0.0 + montant_regle = float(row[3]) if row[3] else 0.0 + client_code = row[4].strip() if row[4] else "" + date_facture = row[5] + reference = row[6].strip() if row[6] else "" + + solde = max(0.0, total_ttc - montant_regle) + + return { + "numero_facture": numero_facture, + "est_validee": valide == 1, + "statut": statut, + "statut_libelle": _get_statut_libelle(statut), + "peut_etre_modifiee": valide == 0 and statut not in (5, 6), + "peut_etre_devalidee": valide == 1 + and montant_regle < 0.01 + and statut not in (5, 6), + "total_ttc": total_ttc, + "montant_regle": montant_regle, + "solde_restant": solde, + "client_code": client_code, + "date_facture": date_facture.strftime("%Y-%m-%d") if date_facture else None, + "reference": reference, + } + + +def _get_montant_regle(self, numero_facture: str) -> float: + """Récupère le montant déjà réglé d'une facture""" + with self._get_sql_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT ISNULL(SUM(DR_MontantRegle), 0) + FROM F_REGLECH + WHERE DO_Piece = ? AND DO_Type = 6 + """, + (numero_facture,), + ) + row = cursor.fetchone() + return float(row[0]) if row else 0.0 + + +def _build_response( + doc, numero_facture: str, deja_valide: bool, action: str = "validation" +) -> Dict: + """Construit la réponse de validation/dévalidation""" + valide = getattr(doc, "DO_Valide", 0) + statut = getattr(doc, "DO_Statut", 0) + total_ht = float(getattr(doc, "DO_TotalHT", 0.0)) + total_ttc = float(getattr(doc, "DO_TotalTTC", 0.0)) + + client_code = "" + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + except Exception: + pass + + if action == "validation": + message = ( + "Facture déjà validée" if deja_valide else "Facture validée avec succès" + ) + else: + message = ( + "Facture déjà non validée" + if deja_valide + else "Facture dévalidée avec succès" + ) + + return { + "numero_facture": numero_facture, + "est_validee": valide == 1, + "statut": statut, + "statut_libelle": _get_statut_libelle(statut), + "total_ht": total_ht, + "total_ttc": total_ttc, + "client_code": client_code, + "message": message, + "action_effectuee": not deja_valide, + } + + +def _get_statut_libelle(statut: int) -> str: + """Retourne le libellé d'un statut de document""" + statuts = { + 0: "Brouillon", + 1: "Confirmé", + 2: "En cours", + 3: "Imprimé", + 4: "Suspendu", + 5: "Transformé", + 6: "Annulé", + } + return statuts.get(statut, f"Statut {statut}") + + +__all__ = [ + "valider_facture", + "devalider_facture", + "get_statut_validation", +] diff --git a/utils/functions/society/societe_data.py b/utils/functions/society/societe_data.py index ea6482f..d031242 100644 --- a/utils/functions/society/societe_data.py +++ b/utils/functions/society/societe_data.py @@ -1,4 +1,3 @@ -from typing import Optional from pathlib import Path import base64 import logging