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>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 12:08:38 -05:00
1 parent 5e533b7a9f
commit 1c57988f54
20 files changed
+991 -130

No files matched your search

+80 -12
View File
@@ -31,6 +31,15 @@ SAPB1_VERIFY_SSL = os.getenv("SAP_B1_VERIFY_SSL", "False").lower() == "true"
_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
# ============================================================================
@@ -41,16 +50,18 @@ def login() -> dict:
"Password": SAPB1_PASSWORD,
}
logger.info("🔐 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}"
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"]
@@ -149,9 +160,9 @@ def forward_to_sapb1(
verify=SAPB1_VERIFY_SSL,
)
except requests.exceptions.Timeout:
raise ConnectionError(f"Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}")
raise SapNoDisponible(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}")
raise SapNoDisponible(f"No se pudo conectar con SAP B1: {exc}")
except requests.RequestException as exc:
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
@@ -180,7 +191,8 @@ def forward_to_sapb1(
)
except (json.JSONDecodeError, AttributeError):
sap_message = resp.text
raise ConnectionError(
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}"
)
@@ -198,6 +210,50 @@ def create_draft(payload: dict) -> dict:
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))
# ============================================================================
@@ -245,6 +301,10 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
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}"
@@ -275,6 +335,8 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
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}")
@@ -284,6 +346,7 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
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:
@@ -311,6 +374,8 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
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 ──────────────────────────────────────────────────
@@ -327,6 +392,9 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
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 []
# ============================================================================