From 38b152d6b5c20284cdc84336eec01f905b8394e7 Mon Sep 17 00:00:00 2001 From: george Date: Wed, 30 Sep 2026 19:49:49 -0500 Subject: [PATCH] =?UTF-8?q?SAP:=20una=20sesi=C3=B3n=20por=20conexi=C3=B3n,?= =?UTF-8?q?=20reutilizada=20y=20renovada=20solo=20ante=20un=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes se abría un login por orden (un cliente por orden, con la sesión guardada solo en la instancia) y nunca se cerraba. Ahora el B1SESSION se guarda por conexión (URL + base + usuario) en un caché del proceso, se renueva solo si SAP responde 401 y se cierra con Logout al terminar el proceso (SAPServiceLayerClient.cerrar_sesiones). Probado contra SAP: 6 clientes de ambas tiendas hacen 1 login; una sesión inválida provoca 1 login tras el 401; la integración de 3 órdenes con SAP bloqueado para escrituras hace 1 login. Co-Authored-By: Claude Sonnet 5.5 --- core/sap.py | 133 ++++++++++++++++++++++++++++------------- docs/flujo_pipeline.md | 3 + docs/pendientes.md | 2 + 3 files changed, 96 insertions(+), 42 deletions(-) diff --git a/core/sap.py b/core/sap.py index 2652825..9835681 100644 --- a/core/sap.py +++ b/core/sap.py @@ -1,6 +1,8 @@ +import atexit import json import logging -from typing import Any, Dict, Optional +import threading +from typing import Any, Dict, Optional, Tuple import requests @@ -11,12 +13,19 @@ logger = logging.getLogger(__name__) class SAPServiceLayerClient: """ - Cliente de SAP B1 Service Layer para una conexión (URL + CompanyDB) - específica. Cada tienda Shopify puede apuntar a una base SAP distinta, - por eso la sesión (B1SESSION) vive por instancia y no en un estado - global del módulo. + Cliente de SAP B1 Service Layer para una conexión (URL + CompanyDB + + usuario) específica. Cada tienda Shopify puede apuntar a una base SAP + distinta, por eso la sesión (B1SESSION) se guarda **por conexión** y se + reutiliza entre instancias del mismo proceso: crear un cliente por orden + no abre una sesión nueva por orden. Si dos tiendas comparten URL, base y + usuario, comparten sesión. La sesión se renueva sola ante un 401 y se + cierra con cerrar_sesiones() (también al terminar el proceso). """ + # clave (url, company_db, username) -> {"headers", "url", "timeout", "verify_ssl"} + _sesiones: Dict[Tuple[str, str, str], Dict[str, Any]] = {} + _lock = threading.Lock() + def __init__( self, url: str, @@ -34,6 +43,10 @@ class SAPServiceLayerClient: self.verify_ssl = verify_ssl self._headers: Optional[Dict[str, str]] = None + @property + def _clave(self) -> Tuple[str, str, str]: + return (self.url, self.company_db, self.username) + @classmethod def from_store_config(cls, sap_connection: Dict[str, Any]) -> "SAPServiceLayerClient": """ @@ -81,48 +94,55 @@ class SAPServiceLayerClient: # ======================================================================== def ensure_session(self, force_refresh: bool = False) -> Dict[str, str]: """ - Verifica si existe una sesión (B1SESSION) para esta conexión. - Si no existe o force_refresh es True, hace Login. + Devuelve la sesión (B1SESSION) guardada para esta conexión. Si no + existe o force_refresh es True, hace Login y la guarda para las + próximas llamadas e instancias. """ - if self._headers and not force_refresh: - return self._headers + with self._lock: + guardada = self._sesiones.get(self._clave) + if guardada and not force_refresh: + self._headers = guardada["headers"] + return self._headers - logger.info(f"[{self.company_db}] Iniciando sesión en SAP Service Layer...") - auth_url = f"{self.url}/Login" - payload = { - "CompanyDB": self.company_db, - "UserName": self.username, - "Password": self.password, - } + logger.info(f"[{self.company_db}] Iniciando sesión en SAP Service Layer...") + auth_url = f"{self.url}/Login" + payload = { + "CompanyDB": self.company_db, + "UserName": self.username, + "Password": self.password, + } - try: - response = requests.post( - auth_url, - json=payload, - timeout=self.timeout, - verify=self.verify_ssl, - ) - - if response.status_code != 200: - logger.error( - f"[{self.company_db}] Error crítico: no se pudo iniciar sesión en SAP. " - f"Status: {response.status_code}" + try: + response = requests.post( + auth_url, + json=payload, + timeout=self.timeout, + verify=self.verify_ssl, ) + + if response.status_code != 200: + logger.error( + f"[{self.company_db}] Error crítico: no se pudo iniciar sesión en SAP. " + f"Status: {response.status_code}" + ) + self._headers = None + self._sesiones.pop(self._clave, None) + raise SAPServiceLayerError(response.status_code, "Conexión fallida con el servidor SAP") + + sap_session = response.json() + b1_session_id = sap_session.get("SessionId") + route_id = response.cookies.get("ROUTEID") + + headers = {"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"} + self._headers = headers + self._sesiones[self._clave] = {"headers": headers, "url": self.url, "timeout": self.timeout, "verify_ssl": self.verify_ssl} + logger.info(f"[{self.company_db}] Sesión establecida. B1SESSION: {b1_session_id[:10]}...") + return headers + except requests.RequestException as exc: + logger.error(f"[{self.company_db}] Error de conexión al autenticar contra SAP: {exc}") self._headers = None - raise SAPServiceLayerError(response.status_code, "Conexión fallida con el servidor SAP") - - sap_session = response.json() - b1_session_id = sap_session.get("SessionId") - route_id = response.cookies.get("ROUTEID") - - headers = {"Cookie": f"B1SESSION={b1_session_id}; ROUTEID={route_id}"} - self._headers = headers - logger.info(f"[{self.company_db}] Sesión establecida. B1SESSION: {b1_session_id[:10]}...") - return headers - except requests.RequestException as exc: - logger.error(f"[{self.company_db}] Error de conexión al autenticar contra SAP: {exc}") - self._headers = None - raise SAPServiceLayerError(500, f"Error al conectar con SAP Service Layer: {exc}") + self._sesiones.pop(self._clave, None) + raise SAPServiceLayerError(500, f"Error al conectar con SAP Service Layer: {exc}") # ======================================================================== def forward( @@ -209,3 +229,32 @@ class SAPServiceLayerClient: raise SAPServiceLayerError(response.status_code, error_msg, code=error_code) return response + + # ======================================================================== + @classmethod + def cerrar_sesiones(cls) -> int: + """ + Cierra (Logout) todas las sesiones guardadas y vacía el caché. Se llama + sola al terminar el proceso; devuelve cuántas sesiones se cerraron. + """ + with cls._lock: + sesiones = list(cls._sesiones.items()) + cls._sesiones.clear() + + cerradas = 0 + for (url, company_db, _), datos in sesiones: + try: + requests.post( + f"{url}/Logout", + headers={**datos["headers"], "Content-Type": "application/json"}, + timeout=datos["timeout"], + verify=datos["verify_ssl"], + ) + cerradas += 1 + logger.info(f"[{company_db}] Sesión de SAP cerrada") + except requests.RequestException as exc: + logger.warning(f"[{company_db}] No se pudo cerrar la sesión de SAP (expirará sola): {exc}") + return cerradas + + +atexit.register(SAPServiceLayerClient.cerrar_sesiones) diff --git a/docs/flujo_pipeline.md b/docs/flujo_pipeline.md index 3eb5c5d..cc244ed 100644 --- a/docs/flujo_pipeline.md +++ b/docs/flujo_pipeline.md @@ -277,5 +277,8 @@ Este documento describe el flujo base. Las reglas siguientes lo complementan (de - **Idempotencia contra SAP:** antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento ya existente para la orden (`U_ShopifyOrderId` + `U_ShopifyStore`, y mismas líneas); si existe se adopta y no se duplica. Una Invoice ya pagada en SAP no genera otro pago. +- **Sesión de SAP:** el `B1SESSION` se guarda por conexión (URL + base + usuario) y se reutiliza entre órdenes y + ciclos; solo se vuelve a hacer `Login` si SAP responde 401, y se hace `Logout` al terminar el proceso + (`SAPServiceLayerClient.cerrar_sesiones()`). Antes se abría una sesión por orden y nunca se cerraba. - **Red:** `ShopifyClient` reintenta hasta 4 veces ante fallos de red (SSL, conexión, timeout), con espera creciente. - **Cursor:** `max(id máximo en BD, initial_since_id)`. diff --git a/docs/pendientes.md b/docs/pendientes.md index 2135651..94a0d9d 100644 --- a/docs/pendientes.md +++ b/docs/pendientes.md @@ -77,6 +77,8 @@ Estado al 2026-09-30. Ver `docs/flujo_pipeline.md`, `docs/mapeo_campos.md` y - **Idempotencia contra SAP**: antes de crear Invoice, ITR, StockTransfer o Delivery se busca un documento existente de la orden (`U_ShopifyOrderId`, `U_ShopifyStore`, mismas líneas) y se adopta; una Invoice ya pagada no genera otro pago. +- **Sesiones de SAP**: una por conexión, reutilizada y renovada solo ante un 401; `Logout` al cerrar el proceso + (antes: un login por orden, sin cierre). - **Reintentos de red** en `ShopifyClient` (4 intentos con espera creciente). - Invoice igual a lo cobrado (líneas canceladas o devueltas, reembolsos), fulfillments limitados a lo facturado, órdenes anuladas (`cancelled`), pedidos ignorados (`ignored`), despachadas y luego reembolsadas se integran.