- 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>
470 lines
18 KiB
Python
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
|