Files
descarga_factura_transporte/sapb1_manager.py
T
GeorgeandClaude Sonnet 5.5 f0ad6aa78e se valida si la factura ya existe en SAP y se avisa cuando SAP está fuera de línea
- sapb1_manager.buscar_factura_existente busca la factura (proveedor, tipo,
  serie, número) en facturas de compra no anuladas y en drafts; si existe no
  se crea otro draft y se responde "ya está registrada" sin pisar el registro
  de una aceptación anterior.
- SapNoDisponible (subclase de ConnectionError) distingue una caída (timeout,
  sin conexión, HTTP 5xx) de un rechazo normal de SAP (HTTP 4xx). Una caída
  de Service Layer con HANA sin resultados ya no se lee como "guía no existe".
- Con SAP o HANA caídos se avisa una sola vez al proveedor con la plantilla
  "En cola" y el correo se reintenta en los ciclos siguientes (hasta 24), sin
  repetir avisos; al agotarse se avisa al equipo.
- La lógica por factura pasa a _procesar_factura; lo ya resuelto dentro de un
  correo no se repite al reintentarlo.
- seguimiento: columna intentos (con migración), registrar_intento y el
  estado de notificación en_espera.
- database_utilities.DB_NAME pasa a ser absoluto (junto al código).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00

470 lines
18 KiB
Python

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