2026-07-09 09:48:30 -05:00
|
|
|
|
|
|
|
|
import requests
|
|
|
|
|
import urllib3
|
|
|
|
|
import os
|
|
|
|
|
import json
|
|
|
|
|
from typing import Optional, Dict, Any
|
|
|
|
|
from dotenv import load_dotenv
|
2026-09-02 07:40:26 -05:00
|
|
|
import hana_manager
|
2026-07-09 09:48:30 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# CONFIGURACIÓN
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
urllib3.disable_warnings()
|
|
|
|
|
|
2026-09-10 13:21:04 -05:00
|
|
|
# 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"))
|
2026-07-09 09:48:30 -05:00
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# LOGIN
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def login() -> dict:
|
|
|
|
|
payload = {
|
|
|
|
|
"CompanyDB": SAPB1_COMPANY,
|
|
|
|
|
"UserName": SAPB1_USER,
|
|
|
|
|
"Password": SAPB1_PASSWORD,
|
|
|
|
|
}
|
|
|
|
|
print("🔐 Iniciando sesión en SAP B1 Service Layer...")
|
|
|
|
|
resp = requests.post(
|
|
|
|
|
f"{SAPB1_URL}/Login",
|
|
|
|
|
json=payload,
|
|
|
|
|
timeout=SAPB1_TIMEOUT,
|
|
|
|
|
verify=SAPB1_VERIFY_SSL,
|
|
|
|
|
)
|
|
|
|
|
if resp.status_code != 200:
|
|
|
|
|
raise ConnectionError(
|
|
|
|
|
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})
|
|
|
|
|
print(f"✅ Sesión SAP B1 establecida (B1SESSION: {b1session[:10]}...)")
|
|
|
|
|
return headers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# LOGOUT
|
|
|
|
|
# ============================================================================
|
|
|
|
|
def logout() -> None:
|
|
|
|
|
if not _sap_session["b1session"]:
|
|
|
|
|
print("⚠️ No hay sesión activa para cerrar.")
|
|
|
|
|
return
|
|
|
|
|
print("🔓 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:
|
|
|
|
|
print("✅ Sesión SAP B1 cerrada correctamente.")
|
|
|
|
|
else:
|
|
|
|
|
print(f"⚠️ Logout respondió HTTP {resp.status_code}")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
print(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:
|
2026-09-10 13:21:04 -05:00
|
|
|
print(f"⚠️ {log_line} - {response.text[:300]}")
|
2026-07-09 09:48:30 -05:00
|
|
|
else:
|
|
|
|
|
print(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)
|
|
|
|
|
|
|
|
|
|
print(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 ConnectionError(f"Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}")
|
|
|
|
|
except requests.exceptions.ConnectionError as exc:
|
|
|
|
|
raise ConnectionError(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:
|
|
|
|
|
print("⚠️ 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
|
|
|
|
|
raise ConnectionError(
|
|
|
|
|
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()
|
|
|
|
|
print(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}")
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# 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()
|
|
|
|
|
print(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida")
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
2026-09-10 13:21:04 -05:00
|
|
|
# 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)
|
2026-07-09 09:48:30 -05:00
|
|
|
# ============================================================================
|
|
|
|
|
def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
|
2026-09-02 07:40:26 -05:00
|
|
|
"""
|
2026-09-10 13:21:04 -05:00
|
|
|
Obtiene el documento completo de SAP B1 a partir del cual se validan los
|
|
|
|
|
precios de flete (líneas ``FL*`` en ``DocumentLines``).
|
2026-09-02 07:40:26 -05:00
|
|
|
|
2026-09-10 13:21:04 -05:00
|
|
|
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).
|
2026-09-02 07:40:26 -05:00
|
|
|
|
2026-09-10 13:21:04 -05:00
|
|
|
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.
|
2026-09-02 07:40:26 -05:00
|
|
|
|
|
|
|
|
Returns:
|
2026-09-10 13:21:04 -05:00
|
|
|
list[dict] — Documentos completos, o [] si no se encontró nada en
|
|
|
|
|
ninguna fuente.
|
2026-09-02 07:40:26 -05:00
|
|
|
"""
|
2026-09-10 13:21:04 -05:00
|
|
|
codigo = f"{tipo}{serie}-{numero}"
|
2026-09-02 07:40:26 -05:00
|
|
|
|
|
|
|
|
# ── Fase 1: Obtener DocEntries ──────────────────────────────────────
|
|
|
|
|
entries = _get_delivery_entries(tipo, serie, numero)
|
|
|
|
|
|
|
|
|
|
if not entries:
|
|
|
|
|
print(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"]
|
2026-09-10 13:21:04 -05:00
|
|
|
entidad = entry["Entidad"]
|
2026-09-02 07:40:26 -05:00
|
|
|
try:
|
2026-09-10 13:21:04 -05:00
|
|
|
resp = forward_to_sapb1("GET", f"{entidad}({doc_entry})")
|
2026-09-02 07:40:26 -05:00
|
|
|
doc = resp.json()
|
2026-09-10 13:21:04 -05:00
|
|
|
print(f" │ └─ {entidad}({doc_entry}) obtenido")
|
|
|
|
|
|
|
|
|
|
if entidad == "StockTransfers":
|
|
|
|
|
num_ov = doc.get("U_IDC_NUMOV")
|
|
|
|
|
if not num_ov:
|
|
|
|
|
print(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()
|
|
|
|
|
print(f" │ └─ Orders({num_ov}) obtenido (Orden de Venta de StockTransfers({doc_entry}))")
|
|
|
|
|
|
2026-09-02 07:40:26 -05:00
|
|
|
docs.append(doc)
|
|
|
|
|
except Exception as e:
|
2026-09-10 13:21:04 -05:00
|
|
|
print(f" │ ⚠️ Error obteniendo {entidad}({doc_entry}): {e}")
|
2026-09-02 07:40:26 -05:00
|
|
|
|
|
|
|
|
return docs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
|
2026-09-10 13:21:04 -05:00
|
|
|
"""Fase 1: intenta SL, fallback a HANA. Retorna [{DocEntry, DocNum, CardCode, CardName, Entidad}, ...]."""
|
|
|
|
|
codigo = f"{tipo}{serie}-{numero}"
|
2026-09-02 07:40:26 -05:00
|
|
|
|
|
|
|
|
# ── Intentar Service Layer ───────────────────────────────────────────
|
|
|
|
|
try:
|
|
|
|
|
filter_str = (
|
|
|
|
|
f"Cancelled eq 'tNO'"
|
2026-09-10 13:21:04 -05:00
|
|
|
f" and U_BPP_MDTD eq '{tipo}'"
|
|
|
|
|
f" and U_BPP_MDSD eq '{serie}'"
|
2026-09-02 07:40:26 -05:00
|
|
|
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:
|
2026-09-10 13:21:04 -05:00
|
|
|
for sl_entry in sl_entries:
|
|
|
|
|
sl_entry["Entidad"] = "DeliveryNotes"
|
2026-09-02 07:40:26 -05:00
|
|
|
print(f" │ 🔍 Guía {codigo}: SL → {len(sl_entries)} DocEntry encontrados")
|
|
|
|
|
return sl_entries
|
|
|
|
|
|
|
|
|
|
print(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(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:
|
2026-09-10 13:21:04 -05:00
|
|
|
for hana_entry in hana_entries:
|
|
|
|
|
hana_entry["Entidad"] = _TABLA_A_ENTIDAD_SL.get(
|
|
|
|
|
hana_entry.get("Tabla"), "DeliveryNotes"
|
|
|
|
|
)
|
2026-09-02 07:40:26 -05:00
|
|
|
print(f" │ 🔍 Guía {codigo}: HANA → {len(hana_entries)} DocEntry encontrados")
|
|
|
|
|
return hana_entries
|
|
|
|
|
print(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f" │ ⚠️ Guía {codigo}: HANA falló ({e})")
|
|
|
|
|
|
|
|
|
|
return []
|
2026-07-09 09:48:30 -05:00
|
|
|
|
2026-09-10 13:21:04 -05:00
|
|
|
# ============================================================================
|
|
|
|
|
# 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:
|
|
|
|
|
print(f" │ ⚠️ Error obteniendo condición de pago de {card_code}: {e}")
|
|
|
|
|
return None
|
|
|
|
|
|
2026-07-09 09:48:30 -05:00
|
|
|
# ============================================================================
|
|
|
|
|
# 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", [])
|
|
|
|
|
print(f" ├── ✅ U_IDC_ZONAS obtenida ({len(value)} registros)")
|
|
|
|
|
return value
|