import logging import requests import urllib3 import os import json from typing import Optional, Dict, Any from dotenv import load_dotenv import hana_manager logger = logging.getLogger(__name__) # ============================================================================ # CONFIGURACIÓN # ============================================================================ urllib3.disable_warnings() # Ruta absoluta: no depende del directorio de trabajo desde el que se lance el proceso load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env")) SAPB1_URL = os.getenv("SAP_B1_URL", "") SAPB1_USER = os.getenv("SAP_B1_USER", "") SAPB1_PASSWORD = os.getenv("SAP_B1_PASSWORD", "") SAPB1_COMPANY = os.getenv("SAP_B1_COMPANY", "") SAPB1_TIMEOUT = float(os.getenv("SAP_B1_TIMEOUT", 15.0)) SAPB1_VERIFY_SSL = os.getenv("SAP_B1_VERIFY_SSL", "False").lower() == "true" # Almacenamiento global de sesión _sap_session: dict = {"headers": None, "b1session": None, "routeid": None} class SapNoDisponible(ConnectionError): """ SAP no responde: timeout, sin conexión o error 5xx. Es una caída de la infraestructura, no un rechazo de SAP (un HTTP 4xx sigue siendo un ``ConnectionError`` normal). Subclase de ``ConnectionError``, así que el código que ya captura ``ConnectionError`` o ``Exception`` no cambia. """ # ============================================================================ # LOGIN # ============================================================================ def login() -> dict: payload = { "CompanyDB": SAPB1_COMPANY, "UserName": SAPB1_USER, "Password": SAPB1_PASSWORD, } logger.info("🔐 Iniciando sesión en SAP B1 Service Layer...") try: resp = requests.post( f"{SAPB1_URL}/Login", json=payload, timeout=SAPB1_TIMEOUT, verify=SAPB1_VERIFY_SSL, ) except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: raise SapNoDisponible(f"No se pudo conectar con SAP B1 para iniciar sesión: {exc}") from exc if resp.status_code != 200: clase = SapNoDisponible if resp.status_code >= 500 else ConnectionError raise clase(f"Login fallido: HTTP {resp.status_code} - {resp.text}") data = resp.json() b1session = data["SessionId"] routeid = resp.cookies.get("ROUTEID", "") headers = {"Cookie": f"B1SESSION={b1session}; ROUTEID={routeid}"} _sap_session.update({"headers": headers, "b1session": b1session, "routeid": routeid}) logger.info(f"✅ Sesión SAP B1 establecida (B1SESSION: {b1session[:10]}...)") return headers # ============================================================================ # LOGOUT # ============================================================================ def logout() -> None: if not _sap_session["b1session"]: logger.warning("⚠️ No hay sesión activa para cerrar.") return logger.info("🔓 Cerrando sesión SAP B1...") try: resp = requests.post( f"{SAPB1_URL}/Logout", headers=_sap_session["headers"], timeout=SAPB1_TIMEOUT, verify=SAPB1_VERIFY_SSL, ) if resp.status_code == 200: logger.info("✅ Sesión SAP B1 cerrada correctamente.") else: logger.warning(f"⚠️ Logout respondió HTTP {resp.status_code}") except Exception as exc: logger.error(f"❌ Error al cerrar sesión: {exc}") finally: _sap_session.update({"headers": None, "b1session": None, "routeid": None}) # ============================================================================ # SESIÓN (interna - lazy login) # ============================================================================ def _ensure_session() -> dict: if _sap_session["headers"]: return _sap_session["headers"] return login() # ============================================================================ # LOGGING DE RESPUESTA # ============================================================================ def _log_response(method: str, endpoint: str, response: requests.Response, payload: Any = None) -> None: log_line = ( f" ├── [{method}] {endpoint} → {response.status_code}" f" ({len(response.content)} bytes)" ) if response.status_code >= 400: logger.warning(f"⚠️ {log_line} - {response.text[:300]}") else: logger.info(log_line) # ============================================================================ # PROXY CENTRALIZADO # ============================================================================ def forward_to_sapb1( method: str, endpoint_path: str, json_data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, extra_headers: Optional[Dict[str, str]] = None ) -> requests.Response: if not method or not endpoint_path: raise ValueError("method y endpoint_path son requeridos") method = method.upper() headers = _ensure_session() url = f"{SAPB1_URL}/{endpoint_path}" req_headers = { "Content-Type": "application/json", "Accept": "application/json", **_sap_session["headers"], } if extra_headers: req_headers.update(extra_headers) logger.info(f" ├── 🔄 [{method}] {endpoint_path}") try: resp = requests.request( method=method, url=url, json=json_data, params=params, headers=req_headers, timeout=SAPB1_TIMEOUT, verify=SAPB1_VERIFY_SSL, ) except requests.exceptions.Timeout: raise SapNoDisponible(f"Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}") except requests.exceptions.ConnectionError as exc: raise SapNoDisponible(f"No se pudo conectar con SAP B1: {exc}") except requests.RequestException as exc: raise ConnectionError(f"Error en solicitud HTTP: {exc}") if resp.status_code == 401: logger.warning("⚠️ Sesión expirada (401). Reautenticando e intentando una vez...") req_headers["Cookie"] = login()["Cookie"] resp = requests.request( method=method, url=url, json=json_data, params=params, headers=req_headers, timeout=SAPB1_TIMEOUT, verify=SAPB1_VERIFY_SSL, ) _log_response(method, endpoint_path, resp, json_data) if resp.status_code >= 400: try: error_body = resp.json() sap_message = ( error_body.get("error", {}) .get("message", {}) .get("value", resp.text) ) except (json.JSONDecodeError, AttributeError): sap_message = resp.text clase = SapNoDisponible if resp.status_code >= 500 else ConnectionError raise clase( f"Error en SAP {method} {endpoint_path}: " f"HTTP {resp.status_code} - {sap_message}" ) return resp # ============================================================================ # CREAR DRAFT (POST /Drafts) # ============================================================================ def create_draft(payload: dict) -> dict: resp = forward_to_sapb1("POST", "Drafts", json_data=payload) result = resp.json() logger.info(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}") return result # ============================================================================ # ¿LA FACTURA YA EXISTE EN SAP? # ============================================================================ def _literal_odata(valor: Any) -> str: """Valor de texto para un $filter de OData: comillas simples duplicadas.""" return str(valor).replace("'", "''") def buscar_factura_existente(ruc_emisor: str, tipo: str, serie: str, numero: str) -> dict | None: """ Busca una factura de proveedor en SAP B1 por proveedor (``PL`` + RUC), tipo, serie y número (``U_BPP_MDTD``/``U_BPP_MDSD``/``U_BPP_MDCD``, los mismos campos que llena el draft). Mira dos lugares: las facturas de compra **no anuladas** y los **drafts** de factura de compra (una factura cuyo draft ya se creó, aunque nadie lo haya contabilizado todavía). Returns: dict | None — ``{"entidad": "PurchaseInvoices" | "Drafts", "DocEntry": int, "DocNum": int}`` del primer documento encontrado, o None si la factura no existe. Raises: SapNoDisponible: Si SAP no responde (no se puede afirmar que no exista). """ base = ( f"CardCode eq 'PL{_literal_odata(ruc_emisor)}'" f" and U_BPP_MDTD eq '{_literal_odata(tipo)}'" f" and U_BPP_MDSD eq '{_literal_odata(serie)}'" f" and U_BPP_MDCD eq '{_literal_odata(numero)}'" ) for entidad, condicion in ( ("PurchaseInvoices", " and Cancelled eq 'tNO'"), ("Drafts", " and DocObjectCode eq 'oPurchaseInvoices'"), ): resp = forward_to_sapb1( "GET", entidad, params={"$filter": base + condicion, "$select": "DocEntry,DocNum", "$top": "1"}, ) encontrados = resp.json().get("value", []) if encontrados: logger.info(f" ├── 🔎 {serie}-{numero} ya existe en SAP ({entidad} {encontrados[0].get('DocEntry')})") return {"entidad": entidad, "DocEntry": encontrados[0].get("DocEntry"), "DocNum": encontrados[0].get("DocNum")} return None # ============================================================================ # OBTENER FACTURA DE COMPRA (GET /PurchaseInvoices(DocEntry)) # ============================================================================ def get_purchase_invoice(doc_entry: int) -> dict: resp = forward_to_sapb1("GET", f"PurchaseInvoices({doc_entry})") result = resp.json() logger.info(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida") return result # ============================================================================ # Tabla HANA de origen → entidad de Service Layer que hay que consultar. # ODLN (Entregas) se expone en SL como DeliveryNotes; OWTR (Traslados de # Inventario) se expone como StockTransfers. # ============================================================================ _TABLA_A_ENTIDAD_SL = { "ODLN": "DeliveryNotes", "OWTR": "StockTransfers", } # ============================================================================ # OBTENER GUIA (GET /DeliveryNotes o /StockTransfers) # ============================================================================ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list: """ Obtiene el documento completo de SAP B1 a partir del cual se validan los precios de flete (líneas ``FL*`` en ``DocumentLines``). Fase 1 — Busca DocEntry/DocNum/CardCode/CardName/Entidad: - Primero intenta Service Layer con $select (entidad DeliveryNotes). - Si no hay resultados, fallback a HANA (consulta ODLN y, si no encuentra nada, OWTR). Fase 2 — Por cada DocEntry obtenido, hace GET a la entidad indicada en "Entidad" (DeliveryNotes o StockTransfers). - Si es DeliveryNotes, ese documento ya trae ``DocumentLines`` con las líneas FL* y se retorna tal cual. - Si es StockTransfers, ese documento es un traslado de inventario y NO tiene líneas FL* ni precios. En ese caso se lee el campo ``U_IDC_NUMOV`` (DocEntry de la Orden de Venta asociada) y se hace GET a ``Orders(U_IDC_NUMOV)``, devolviendo ese documento en su lugar para que la validación de precios funcione igual que con DeliveryNotes. Returns: list[dict] — Documentos completos, o [] si no se encontró nada en ninguna fuente. Raises: SapNoDisponible: Si Service Layer no responde y HANA no encontró nada (no se puede afirmar que la guía no exista). """ codigo = f"{tipo}{serie}-{numero}" # ── Fase 1: Obtener DocEntries ────────────────────────────────────── entries = _get_delivery_entries(tipo, serie, numero) if not entries: logger.warning(f" │ ⚠️ Guía {codigo}: sin registros en SAP (SL + HANA)") return [] # ── Fase 2: Obtener documento completo por cada DocEntry ───────────── docs = [] for entry in entries: doc_entry = entry["DocEntry"] entidad = entry["Entidad"] try: resp = forward_to_sapb1("GET", f"{entidad}({doc_entry})") doc = resp.json() logger.info(f" │ └─ {entidad}({doc_entry}) obtenido") if entidad == "StockTransfers": num_ov = doc.get("U_IDC_NUMOV") if not num_ov: logger.warning(f" │ ⚠️ StockTransfers({doc_entry}) sin U_IDC_NUMOV: no se puede validar precios") continue resp_ov = forward_to_sapb1("GET", f"Orders({num_ov})") doc = resp_ov.json() logger.info(f" │ └─ Orders({num_ov}) obtenido (Orden de Venta de StockTransfers({doc_entry}))") docs.append(doc) except SapNoDisponible: raise except Exception as e: logger.warning(f" │ ⚠️ Error obteniendo {entidad}({doc_entry}): {e}") return docs def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list: """Fase 1: intenta SL, fallback a HANA. Retorna [{DocEntry, DocNum, CardCode, CardName, Entidad}, ...].""" codigo = f"{tipo}{serie}-{numero}" service_layer_caido = None # ── Intentar Service Layer ─────────────────────────────────────────── try: filter_str = ( f"Cancelled eq 'tNO'" f" and U_BPP_MDTD eq '{tipo}'" f" and U_BPP_MDSD eq '{serie}'" f" and contains(U_BPP_MDCD,'{numero}')" ) resp = forward_to_sapb1( "GET", "DeliveryNotes", params={ "$filter": filter_str, "$select": "DocEntry,DocNum,CardCode,CardName", "$top": "10", }, ) sl_entries = resp.json().get("value", []) if sl_entries: for sl_entry in sl_entries: sl_entry["Entidad"] = "DeliveryNotes" logger.info(f" │ 🔍 Guía {codigo}: SL → {len(sl_entries)} DocEntry encontrados") return sl_entries logger.info(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...") except Exception as e: if isinstance(e, SapNoDisponible): service_layer_caido = e logger.warning(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...") # ── Fallback a HANA ────────────────────────────────────────────────── try: hana_entries = hana_manager.get_delivery(tipo, serie, numero) if hana_entries: for hana_entry in hana_entries: hana_entry["Entidad"] = _TABLA_A_ENTIDAD_SL.get( hana_entry.get("Tabla"), "DeliveryNotes" ) logger.info(f" │ 🔍 Guía {codigo}: HANA → {len(hana_entries)} DocEntry encontrados") return hana_entries logger.info(f" │ 🔍 Guía {codigo}: HANA → 0 registros") except Exception as e: logger.warning(f" │ ⚠️ Guía {codigo}: HANA falló ({e})") if service_layer_caido is not None: # SL no respondió y HANA no aportó nada: no es "la guía no existe", es una caída raise SapNoDisponible(f"Service Layer no disponible y HANA sin resultados para la guía {codigo}: {service_layer_caido}") return [] # ============================================================================ # OBTENER CONDICIÓN DE PAGO (GET /BusinessPartners + /PaymentTermsTypes) # ============================================================================ def obtener_condicion_pago(card_code: str) -> dict | None: """ Obtiene la condición de pago (PaymentTermsTypes) de un Business Partner, para calcular la fecha de vencimiento de un documento. Args: card_code: Código del Business Partner (ej. "PL20555929006"). Returns: dict | None — {"GroupNumber", "PaymentTermsGroupName", "NumberOfAdditionalMonths", "NumberOfAdditionalDays"}, o None si el CardCode no existe, no tiene condición de pago asociada, o falló la consulta a Service Layer. """ try: resp_bp = forward_to_sapb1( "GET", f"BusinessPartners('{card_code}')", params={"$select": "CardCode,PayTermsGrpCode"}, ) group_num = resp_bp.json().get("PayTermsGrpCode") if group_num is None: return None resp_terms = forward_to_sapb1("GET", f"PaymentTermsTypes({group_num})") terms = resp_terms.json() return { "GroupNumber": terms.get("GroupNumber"), "PaymentTermsGroupName": terms.get("PaymentTermsGroupName"), "NumberOfAdditionalMonths": terms.get("NumberOfAdditionalMonths") or 0, "NumberOfAdditionalDays": terms.get("NumberOfAdditionalDays") or 0, } except Exception as e: logger.warning(f" │ ⚠️ Error obteniendo condición de pago de {card_code}: {e}") return None # ============================================================================ # OBTENER ZONAS (GET /U_IDC_ZONAS) # ============================================================================ def get_zonas() -> list: filter_str = ( f"U_Activo eq 'Y'" ) sap_params = { "$skip": "0", "$top": "100", "$inlinecount": "allpages" } sap_params["$select"] = "Code, Name" base_headers = {"B1S-PageSize": "100"} if filter_str: sap_params["$filter"] = filter_str resp = forward_to_sapb1( method="GET" , endpoint_path="U_IDC_ZONAS" , params=sap_params , extra_headers=base_headers ) result = resp.json() value = result.get("value", []) logger.info(f" ├── ✅ U_IDC_ZONAS obtenida ({len(value)} registros)") return value