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:
1 parent
5e533b7a9f
commit
1c57988f54
20 files changed
+991
-130
No files matched your search
+80
-12
@@ -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 []
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in new issue
Block a user