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 4deb9d4e4d
commit f0ad6aa78e
20 files changed
+985 -124

No files matched your search

+11 -3
View File
@@ -1,10 +1,13 @@
import os
import sqlite3
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
DB_NAME = "emails.db"
# Junto al código, no relativa al directorio de trabajo: el demonio (por ejemplo como servicio de
# Windows) puede lanzarse desde cualquier carpeta y debe encontrar siempre la misma base.
DB_NAME = os.path.join(os.path.dirname(os.path.abspath(__file__)), "emails.db")
# ============================================================================
def init_db() -> None:
@@ -48,14 +51,19 @@ def init_db() -> None:
notificacion_error TEXT,
notificacion_fecha TEXT,
notificacion_destinatario TEXT,
intentos INTEGER NOT NULL DEFAULT 0,
creado_en TEXT NOT NULL,
actualizado_en TEXT NOT NULL
)
''')
# Bases creadas antes de agregar una columna: se añade sin tocar los datos.
columnas = {fila[1] for fila in cursor.execute("PRAGMA table_info(comprobantes)")}
if "notificacion_destinatario" not in columnas:
cursor.execute("ALTER TABLE comprobantes ADD COLUMN notificacion_destinatario TEXT")
for columna, definicion in (
("notificacion_destinatario", "TEXT"),
("intentos", "INTEGER NOT NULL DEFAULT 0"),
):
if columna not in columnas:
cursor.execute(f"ALTER TABLE comprobantes ADD COLUMN {columna} {definicion}")
cursor.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento
+11 -6
View File
@@ -70,12 +70,15 @@ flowchart TD
O -- No --> RC
O -- Sí --> P[Bucle: por cada factura]
P --> Q[12. validate_prices]:::highlight
P --> P2{11.5 ¿Ya existe en SAP?}
P2 -- Sí --> V
P2 -- No --> Q[12. validate_prices]:::highlight
Q --> Q2[12.5 obtener_valor_referencial_sap]
Q2 --> R[13. build_sap_draft]:::highlight
R --> S[14. guardar_draft_json]
S --> T{¿Error de SAP/HANA?}
T -- Sí --> X[estado error]
S --> T{¿SAP/HANA caídos<br>u otro error?}
T -- Caída --> FL[estado fuera de línea:<br>aviso 'En cola' una vez<br>y el correo se reintenta]
T -- Otro error --> X[estado error]
T -- No --> T2{Todas coinciden?}
T2 -- Sí --> U[15. sapb1_manager.create_draft]
T2 -- No --> V[estado rechazado + observaciones]
@@ -83,6 +86,7 @@ flowchart TD
U2 --> W[16. Correo técnico al equipo<br>+ respuesta con plantilla al remitente]
V --> W
X --> W2[16. Correo técnico al equipo<br>NO se responde al proveedor]
FL --> P
W --> P
W2 --> P
@@ -193,12 +197,13 @@ run_pipeline
| Resultado de la factura | Correo técnico (tablas de SAP, JSON adjuntos) | Respuesta con plantilla |
|-------------------------|-----------------------------------------------|-------------------------|
| `aceptado` (draft creado) | Equipo | Aceptado, al remitente |
| `rechazado` (precio, guía…) | Equipo | Rechazado con observaciones, al remitente |
| `error` (SAP/HANA, fallo inesperado) | Equipo | **Ninguna**: es una falla nuestra, no del proveedor |
| `rechazado` (precio, guía, o la factura **ya existe en SAP**) | Equipo | Rechazado con observaciones, al remitente |
| `fuera_de_linea` (SAP o HANA no responden) | Equipo (primer intento y al agotarse) | "En cola", **una sola vez**; el correo se reintenta |
| `error` (fallo inesperado, rechazo normal de SAP) | Equipo | **Ninguna**: es una falla nuestra, no del proveedor |
| Correo sin comprobante válido (sin PDF/XML, sin XML, sin factura) | — | Rechazado sin comprobante, al remitente |
| Correo sin adjuntos | — | Ninguna |
"Al remitente" depende de `NOTIFICACION_MODO` (en modo `prueba` va al equipo) y de las protecciones. Todo en [respuesta_proveedor.md](respuesta_proveedor.md).
Un fallo inesperado al procesar una factura ya no interrumpe el ciclo: se registra como `error`, se avisa al equipo y se sigue con la siguiente.
Un fallo inesperado al procesar una factura ya no interrumpe el ciclo: se registra como `error`, se avisa al equipo y se sigue con la siguiente. La lógica por factura vive en `_procesar_factura`; `_procesar_un_correo` la llama una vez por factura y, si alguna pide reintento, deja el correo en cola.
+28 -5
View File
@@ -10,13 +10,35 @@ Piezas: `respuesta_proveedor.py` (contexto, protecciones, envío), `observacione
|-----------|------------------------|
| Draft creado en SAP | **Aceptado** |
| Precios que no coinciden, guía inexistente, línea sin guía, cantidad cero | **Rechazado** con una observación por motivo |
| La factura **ya existe en SAP** (factura de compra no anulada o draft) | **Rechazado**: "Esta factura ya está registrada". No se crea otro draft |
| Correo con adjuntos pero sin PDF ni XML válido, o con PDF y sin XML, o sin ninguna factura (tipo 01) | **Rechazado** sin comprobante: no muestra proveedor, número ni tabla de guías |
| Error de SAP o de HANA, o un fallo inesperado | **Nada**: es una falla nuestra. Solo se avisa al equipo |
| **SAP o HANA no responden** | **Aviso "En cola"** (una sola vez): se reintentará solo, no hace falta reenviar. Ver más abajo |
| Fallo inesperado, o un rechazo normal de SAP al crear el draft (HTTP 4xx) | **Nada**: es una falla nuestra. Solo se avisa al equipo |
| Correo sin adjuntos | **Nada** |
Una respuesta por comprobante: un correo con 3 facturas genera 3 respuestas. La aceptación significa "draft creado en SAP". La diferencia de valor referencial XML vs SAP es solo informativa y no rechaza.
> **Guía "no encontrada" y caídas de HANA.** `get_delivery_notes` devuelve lista vacía tanto si la guía no existe como si SAP/HANA fallan. Antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, se trata como error interno y no se le dice nada al proveedor.
> **Guía "no encontrada" y caídas.** `get_delivery_notes` ya no confunde una caída con una guía inexistente: si Service Layer no responde y HANA no aporta nada, lanza `SapNoDisponible`. Además, antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, es "servicio fuera de línea" y no un rechazo.
## ¿La factura ya existe en SAP?
Antes de validar precios, `sapb1_manager.buscar_factura_existente` busca el comprobante por proveedor (`PL` + RUC), tipo, serie y número (`U_BPP_MDTD`/`U_BPP_MDSD`/`U_BPP_MDCD`) en las **facturas de compra no anuladas** y en los **drafts** de factura de compra. Si existe, no se crea otro draft y se responde con la observación `factura_ya_registrada`. Si el comprobante ya estaba **aceptado** en `comprobantes`, ese registro (estado y `doc_entry`) no se sobrescribe.
> La plantilla de rechazo dice "No pudimos registrar su factura… Corríjalas y envíenos el comprobante nuevamente", que no encaja del todo con un duplicado: la observación aclara que no hace falta reenviarla. Si prefiere una plantilla propia para este caso, es un cambio pequeño.
## Servicio fuera de línea y reintentos
`SapNoDisponible` (subclase de `ConnectionError`) se lanza solo ante una **caída**: timeout, sin conexión, HTTP 5xx o login imposible por esos motivos. Un HTTP 4xx (rechazo de SAP) o credenciales inválidas siguen siendo un `ConnectionError` normal y **no** se tratan como caída.
Cuando una factura se encuentra con una caída:
1. Se suma un intento en `comprobantes.intentos` y el resultado queda `error` con la observación `servicio_no_disponible`.
2. **Solo en el primer intento** se envía al proveedor la plantilla `plantilla-fuera-de-linea.html` ("En cola, la procesaremos automáticamente, no necesita reenviarla") y se avisa al equipo. La notificación queda `en_espera`. Los reintentos siguientes **no repiten** ningún aviso.
3. El correo se **elimina de `emails`**, de modo que el siguiente ciclo del demonio lo vuelve a tomar.
4. Cuando SAP vuelve, se procesa con normalidad y el proveedor recibe el aceptado o rechazado.
5. Tras `MAX_REINTENTOS` (24) intentos se deja de reintentar, se avisa al equipo ("Reintentos agotados") y el proveedor no recibe nada más.
Al reintentar un correo, lo que ya se resolvió dentro de él (otra factura del mismo correo) no se repite ni se responde dos veces. Sin seguimiento (si falla la base de datos) no se puede contar intentos, así que una caída se trata como error interno y no se reintenta.
## Modo prueba y modo real
@@ -62,6 +84,7 @@ Los textos son un **borrador para revisar**; se cambian en ese archivo sin tocar
| `guia_no_encontrada` | SAP no devuelve datos para la guía |
| `linea_sin_guia` | Una línea no indica su guía de remisión |
| `cantidad_cero` | Una línea tiene cantidad cero |
| `factura_ya_registrada` | La factura ya existe en SAP |
| `sin_pdf_xml` | El correo trae adjuntos, pero ninguno es PDF ni XML |
| `falta_xml` | Hay PDF y ningún XML |
| `sin_factura` | El XML no contiene una factura electrónica (tipo 01) |
@@ -85,8 +108,8 @@ El buzón `EMAIL` es también el `envio_correo` al que se pide reenviar el compr
## Limitaciones conocidas
- **Un fallo de envío no se reintenta.** Queda `fallida` en `comprobantes`, pero el correo ya está marcado como procesado.
- **Si SAP está caído, el correo tampoco se reintenta.** Se avisa al equipo y el correo queda procesado (con estado `error`).
- **Un comprobante ya aceptado que se reenvía** genera otro draft y otra respuesta "Aceptado"; hoy solo se advierte en el log.
- **Un fallo de envío no se reintenta.** Queda `fallida` en `comprobantes`, pero el correo ya está marcado como procesado. (Solo se reintentan los correos cuando SAP está caído.)
- Los reintentos de SAP caído dependen de que el correo siga dentro de la ventana `--dias` del demonio (2 días por defecto).
- Un error que no es una caída (por ejemplo un HTTP 400 de SAP al crear el draft) no se reintenta: se avisa al equipo.
- La respuesta va a la dirección `from` del mensaje. Si alguien reenvía la factura de un proveedor, la respuesta le llega a quien reenvió.
- No se prueba contra el logo real ni contra Outlook de escritorio: hay que revisar los correos a mano.
+6 -3
View File
@@ -21,7 +21,8 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `observaciones` | JSON con los motivos de rechazo o el detalle del error |
| `doc_entry` | DocEntry del draft creado en SAP |
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida` u `omitida`, y su motivo |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida`, `omitida` o `en_espera` (se avisó que el servicio está fuera de línea), y su motivo |
| `intentos` | Veces que se intentó procesar el comprobante con SAP fuera de línea (tope: `MAX_REINTENTOS`) |
| `notificacion_destinatario` | Direcciones a las que se envió **de verdad** (en modo prueba no son las del remitente) |
| `creado_en`, `actualizado_en` | Marcas de tiempo |
@@ -34,6 +35,7 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `obtener(codigo)` | Devuelve el registro o `None`. |
| `actualizar_resultado(codigo, estado, observaciones=None, doc_entry=None)` | Guarda el resultado. `ValueError` si el estado no es válido, `LookupError` si no existe el código. |
| `registrar_notificacion(codigo, estado, error=None, destinatario=None)` | Guarda el estado de la notificación y a quién se envió. Mismas excepciones. |
| `registrar_intento(codigo)` | Suma un intento fallido por SAP fuera de línea y devuelve el total. |
| `contar_respuestas_correo(remitente, horas=24)` | Respuestas `enviada` a ese remitente en correos sin comprobante: el freno contra bucles de `respuesta_proveedor`. |
## Uso en el pipeline
@@ -46,7 +48,7 @@ En `pipeline._procesar_un_correo`, cada factura se registra al empezar (`_regist
| Precios no coinciden con SAP | `rechazado` | `[{"codigo": "precios_no_coinciden"}]` |
| Error al crear el draft | `error` | `[{"codigo": "error_sap", "detalle": "..."}]` |
`init_db` agrega la columna `notificacion_destinatario` a una tabla creada antes sin ella, sin tocar los datos.
`init_db` agrega las columnas `notificacion_destinatario` e `intentos` a una tabla creada antes sin ellas, sin tocar los datos.
El seguimiento **nunca impide procesar la factura**: si falla la base de datos, se registra un `WARNING` y el flujo continúa. Si el comprobante ya había llegado antes, se registra un `WARNING` con las veces recibidas y el estado anterior, pero **no se bloquea** la creación de otro draft.
@@ -54,11 +56,12 @@ El seguimiento **nunca impide procesar la factura**: si falla la base de datos,
- Rechazo de una factura: la lista de observaciones completa (`codigo`, `titulo`, `filas`, `como_corregir`; ver `observaciones.py`).
- Error: `[{"codigo": "error_interno", "detalle": "..."}]`.
- SAP fuera de línea: `[{"codigo": "servicio_no_disponible", "detalle": "...", "intentos": n}]`.
- Correo sin comprobante: la observación del motivo (`sin_pdf_xml`, `falta_xml`, `sin_factura`).
Las notificaciones las registra `respuesta_proveedor` (`enviada`, `fallida` u `omitida` con su motivo).
## Notas
- `database_utilities.DB_NAME` es `"emails.db"`, una ruta **relativa al directorio de trabajo**. Si el demonio se lanza desde otra carpeta, usará (y creará) otra base y los códigos ya emitidos no se encontrarían. Hoy funciona porque se ejecuta desde la raíz del proyecto.
- `database_utilities.DB_NAME` es `emails.db` **junto al código** (ruta absoluta): no depende del directorio desde el que se lance el demonio, por ejemplo como servicio de Windows.
- Las pruebas redirigen `DB_NAME` a una base temporal (fixture automático en `tests/conftest.py`) para no escribir en `emails.db`.
+2
View File
@@ -368,6 +368,8 @@ def validate_prices(factura: dict) -> tuple[bool, list[dict]]:
guias_lt.append({"guia": guia, "line_total": suma_line_total, "warehouse_code": ", ".join(sorted(wh_set)) if wh_set else "A-CENTRA", "quantity": max_qty, "price_after_vat_sum": sum_pav, "zona":zona, "weight1": weight1, "draft_quantity": 0, "draft_price": 0.0})
all_wh.update(wh_set)
except sapb1_manager.SapNoDisponible:
raise # una caída de SAP no es un problema de la guía: lo decide quien llama
except Exception as ex:
log(f"⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}", 2, severity=logging.WARNING)
guias_lt.append({"guia": guia, "line_total": 0.0, "warehouse_code": "A-CENTRA", "weight1": 0.0, "quantity": 0.0, "price_after_vat_sum": 0.0, "zona": "", "draft_quantity": 0, "draft_price": 0.0, "error": str(ex)})
+5 -1
View File
@@ -80,7 +80,8 @@ def build_notification_body(status: str, factura: dict, validation_table: str, v
del proceso SAP.
Args:
status: ``"created"`` (draft creado), ``"error"`` (error al
status: ``"created"`` (draft creado), ``"duplicate"`` (la factura ya
existe en SAP), ``"error"`` (error al
crear), o cualquier otro valor (validación falló, draft no
creado).
factura: Diccionario de la factura (se usa ``serie``,
@@ -111,6 +112,9 @@ def build_notification_body(status: str, factura: dict, validation_table: str, v
valref_section = f"<h3>Valor Referencial (XML vs SAP)</h3>{valor_referencial_table}"
if status == "created":
return f"<p>✅ Draft SAP B1 creado exitosamente.</p><p><b>DocEntry:</b> {kwargs.get('doc_entry')}</p>{base}<h3>Validación de precios</h3>{validation_table}{valref_section}"
elif status == "duplicate":
existente = kwargs.get("existente") or {}
return f"<p>⏭️ Draft NO creado: la factura ya existe en SAP ({existente.get('entidad')} {existente.get('DocEntry')}).</p>{base}"
elif status == "error":
return f"<p>❌ Error al crear Draft SAP B1.</p>{base}<p><b>Error:</b> {kwargs.get('error')}</p><h3>Validación de precios</h3>{validation_table}{valref_section}"
else:
+8
View File
@@ -83,6 +83,14 @@ def linea_sin_guia(numero_linea: int, descripcion: str) -> Observacion:
)
def factura_ya_registrada(numero_comprobante: str) -> Observacion:
return Observacion(
"factura_ya_registrada", "Esta factura ya está registrada en nuestro sistema",
(("Comprobante", numero_comprobante),),
"No necesita enviarla otra vez. Si tiene dudas sobre su estado, escríbanos indicando el código de seguimiento.",
)
def cantidad_cero(numero_linea: int) -> Observacion:
return Observacion(
"cantidad_cero", "Una línea de la factura tiene cantidad cero", (("Línea", str(numero_linea)),),
+179 -86
View File
@@ -28,6 +28,9 @@ from pipeline_utils import log
logger = logging.getLogger(__name__)
# Veces que se reintenta un comprobante mientras SAP está fuera de línea (un intento por ciclo del demonio)
MAX_REINTENTOS = 24
# ============================================================================
# SEGUIMIENTO (códigos y resultado por comprobante)
@@ -90,6 +93,174 @@ def _rechazar_correo(email: dict, token: str, email_addr: str, lista_obs: list)
)
def _contar_intento(registro: dict | None) -> int | None:
"""Suma un intento al comprobante (SAP fuera de línea). None si no se puede llevar la cuenta."""
if not registro:
return None
try:
return seguimiento.registrar_intento(registro["codigo_seguimiento"])
except Exception as ex:
log(f"⚠️ No se pudo contar el reintento en seguimiento: {ex}", severity=logging.WARNING)
return None
def _procesar_factura(email: dict, token: str, email_addr: str, factura: dict, output_folder: str) -> bool:
"""
Valida una factura contra SAP, crea el draft si corresponde y notifica el resultado.
Resultados posibles: ``aceptado`` (draft creado), ``rechazado`` (precios, guías o factura ya
registrada en SAP), ``error`` (falla nuestra: solo se avisa al equipo) o ``fuera_de_linea``
(SAP o HANA no responden: se avisa una vez al proveedor y se reintenta).
Returns:
True si hay que reintentar el correo en el próximo ciclo (SAP fuera de línea).
"""
serie = factura.get("serie", "?")
numero = factura.get("numero", "?")
log(f"📄 Factura: {serie}-{numero}")
registro = _registrar_comprobante(email, factura)
# Al reintentar un correo, lo que ya se resolvió no se repite (ni se responde dos veces)
if registro and not registro["reenvio"] and registro["estado"] in ("aceptado", "rechazado"):
log(f"⏭️ {serie}-{numero} ya se resolvió al procesar este correo ({registro['estado']}): no se repite")
return False
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
estado = None # aceptado | rechazado | error | fuera_de_linea
detalle_error = None
existente = None
todas_coinciden = False
lista_obs: list = []
resultados_val: list = []
draft: dict = {}
doc_entry = None
validation_table = valor_referencial_table = ""
try:
# 11.5 ¿La factura ya existe en SAP (factura de compra o draft)?
existente = sapb1_manager.buscar_factura_existente(
factura.get("ruc_emisor") or "", factura.get("tipo", "01"), serie, numero,
)
if existente:
lista_obs = [observaciones.factura_ya_registrada(f"{serie}-{numero}")]
else:
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
validation_table = notificaciones.build_validation_html(resultados_val)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
draft = draft_sap.build_sap_draft(factura, resultados_val)
# 14. Guardar draft JSON (siempre, para depuración)
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
detalle_error = draft_sap.hay_error_de_consulta(resultados_val)
if not todas_coinciden:
lista_obs = observaciones.desde_validacion(factura, resultados_val)
# "guía no encontrada" también es lo que se ve si HANA está caído: no culpar al proveedor
if (not detalle_error and any(o.codigo == "guia_no_encontrada" for o in lista_obs)
and not draft_sap.hana_disponible()):
raise sapb1_manager.SapNoDisponible("SAP HANA no responde: no se puede confirmar que las guías no existan")
except sapb1_manager.SapNoDisponible as ex:
estado, detalle_error = "fuera_de_linea", str(ex)
except Exception as ex:
detalle_error = f"{type(ex).__name__}: {ex}"
# 15. Crear el draft en SAP o decidir el resultado
if estado is None:
if detalle_error:
estado = "error"
elif existente or not todas_coinciden:
estado = "rechazado"
else:
try:
doc_entry = sapb1_manager.create_draft(draft).get("DocEntry")
estado = "aceptado"
except sapb1_manager.SapNoDisponible as ex:
estado, detalle_error = "fuera_de_linea", str(ex)
except Exception as ex:
estado, detalle_error = "error", str(ex)
if estado == "aceptado":
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
elif estado == "rechazado":
motivo = "la factura ya existe en SAP" if existente else "validación falló"
log(f"⏭️ Draft NO creado: {serie}-{numero} — {motivo}")
if not (existente and registro and registro["estado"] == "aceptado"): # no pisar el registro de la aceptación original
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
html_body = notificaciones.build_notification_body(
"duplicate" if existente else "skipped", factura, validation_table, valor_referencial_table, existente=existente)
else: # error | fuera_de_linea
log(f"❌ {'SAP fuera de línea' if estado == 'fuera_de_linea' else 'Error'} en {serie}-{numero}: {detalle_error}",
severity=logging.ERROR)
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
# SAP fuera de línea: se cuenta el intento y se avisa solo la primera vez
reintentar = False
primera_vez = agotado = False
if estado == "fuera_de_linea":
intentos = _contar_intento(registro)
if intentos is None: # sin seguimiento no se puede reintentar con seguridad
estado = "error"
else:
primera_vez, agotado = intentos == 1, intentos >= MAX_REINTENTOS
reintentar = not agotado
_registrar_resultado(registro, "error", observaciones=[
{"codigo": "servicio_no_disponible", "detalle": detalle_error, "intentos": intentos}])
log(f"🔁 Intento {intentos} de {MAX_REINTENTOS}" + (": se agotaron los reintentos" if agotado else ""),
severity=logging.WARNING)
if estado == "error":
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
# 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
if not token:
return reintentar
asunto_tecnico = f"Draft SAP B1 - {serie}-{numero}"
if estado == "fuera_de_linea":
asunto_tecnico = ("Reintentos agotados - " if agotado else "SAP fuera de línea - ") + asunto_tecnico
if estado != "fuera_de_linea" or primera_vez or agotado: # no repetir el aviso en cada reintento
respuesta_proveedor.enviar_a_equipo(
token, email_addr, asunto_tecnico,
respuesta_proveedor.encabezado_tecnico(email, registro) + html_body, adjuntos_email,
)
if registro is None:
log("⚠️ Sin código de seguimiento: no se responde al proveedor", severity=logging.WARNING)
elif estado == "aceptado":
respuesta_proveedor.responder(
token, email_addr, email, "aceptado",
respuesta_proveedor.contexto_aceptado(email, registro, factura, resultados_val, draft), registro,
)
elif estado == "rechazado":
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_factura(email, registro, factura, resultados_val, lista_obs), registro,
)
elif estado == "fuera_de_linea":
if primera_vez:
respuesta_proveedor.responder(
token, email_addr, email, "fuera-de-linea",
respuesta_proveedor.contexto_fuera_de_linea(email, registro, factura), registro, estado_exito="en_espera",
)
elif agotado:
respuesta_proveedor.omitir_respuesta(registro, "se agotaron los reintentos: lo revisa el equipo")
else:
respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
return reintentar
# ============================================================================
# PASO 1: Autenticación Microsoft Graph
# ============================================================================
@@ -221,96 +392,18 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
[observaciones.sin_factura(sorted({d.get("tipo_descripcion", "?") for d in documentos}))])
return
reintentar = False
for factura in facturas:
serie = factura.get("serie", "?")
numero = factura.get("numero", "?")
log(f"📄 Factura: {serie}-{numero}")
registro = _registrar_comprobante(email, factura)
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
estado = None # aceptado | rechazado | error
detalle_error = None
lista_obs: list = []
resultados_val: list = []
draft: dict = {}
validation_table = valor_referencial_table = ""
if _procesar_factura(email, token, email_addr, factura, output_folder):
reintentar = True
if reintentar:
# SAP fuera de línea: se olvida el correo para que el próximo ciclo lo procese de nuevo
try:
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
validation_table = notificaciones.build_validation_html(resultados_val)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
draft = draft_sap.build_sap_draft(factura, resultados_val)
# 14. Guardar draft JSON (siempre, para depuración)
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
detalle_error = draft_sap.hay_error_de_consulta(resultados_val)
if not todas_coinciden:
lista_obs = observaciones.desde_validacion(factura, resultados_val)
# "guía no encontrada" también es lo que se ve si HANA está caído: no culpar al proveedor
if (not detalle_error and any(o.codigo == "guia_no_encontrada" for o in lista_obs)
and not draft_sap.hana_disponible()):
detalle_error = "SAP HANA no responde: no se puede confirmar que las guías no existan"
database_utilities.delete_email_by_id(email["id"])
log("🔁 Correo en cola: se reintentará en el próximo ciclo")
except Exception as ex:
detalle_error = f"{type(ex).__name__}: {ex}"
# 15. Crear draft en SAP o generar el resultado
if detalle_error:
estado = "error"
log(f"❌ Error validando {serie}-{numero}: {detalle_error}", severity=logging.ERROR)
elif todas_coinciden:
try:
sap_result = sapb1_manager.create_draft(draft)
doc_entry = sap_result.get("DocEntry")
estado = "aceptado"
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
except Exception as ex:
estado, detalle_error = "error", str(ex)
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
else:
estado = "rechazado"
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
if estado == "error":
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
# 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
if not token:
continue
respuesta_proveedor.enviar_a_equipo(
token, email_addr, f"Draft SAP B1 - {serie}-{numero}",
respuesta_proveedor.encabezado_tecnico(email, registro) + html_body, adjuntos_email,
)
if registro is None:
log("⚠️ Sin código de seguimiento: no se responde al proveedor", severity=logging.WARNING)
elif estado == "aceptado":
respuesta_proveedor.responder(
token, email_addr, email, "aceptado",
respuesta_proveedor.contexto_aceptado(email, registro, factura, resultados_val, draft), registro,
)
elif estado == "rechazado":
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_factura(email, registro, factura, resultados_val, lista_obs), registro,
)
else:
respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
log(f"⚠️ No se pudo dejar el correo en cola: {ex}", severity=logging.WARNING)
# ============================================================================
+17
View File
@@ -276,5 +276,22 @@
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": false
}
},
"fuera_de_linea": {
"plantilla": "fuera-de-linea",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "En cola · Factura F002-486 · ABR-261019-00048",
"preheader": "Su factura F002-486 quedó en cola: nuestro sistema está fuera de línea. No necesita reenviarla.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00048"
}
}
}
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#FFF4E0" style="background:#FFF4E0;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#9A5B00;"><span aria-hidden="true" style="color:#9A5B00;">&#9679;&nbsp;</span><span style="color:#9A5B00;">EN COLA</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">Recibimos su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Nuestro sistema está <strong>temporalmente fuera de línea</strong> y no pudimos validarla todavía. Quedó en cola y la procesaremos automáticamente. <strong>No necesita reenviarla.</strong></p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#9A5B00;">En cola · servicio fuera de línea</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Qué sigue</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Le enviaremos la respuesta (aceptado o rechazado) a este mismo correo apenas se procese.<br>Conserve el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong> para cualquier consulta.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+19 -4
View File
@@ -192,6 +192,17 @@ def _bloque_observaciones(observaciones: list[obs.Observacion]) -> dict:
}
def contexto_fuera_de_linea(email: dict, registro: dict, factura: dict) -> dict:
"""Aviso de que SAP no está disponible: el comprobante quedó en cola y se procesará solo."""
comprobante = _comprobante(factura)
numero = comprobante["comprobante_numero"]
return {
**_comun(email, registro), **comprobante,
"asunto": f"En cola · Factura {numero} · {registro['codigo_seguimiento']}",
"preheader": f"Su factura {numero} quedó en cola: nuestro sistema está fuera de línea. No necesita reenviarla.",
}
def contexto_rechazo_factura(email: dict, registro: dict, factura: dict, resultados_val: list[dict],
observaciones: list[obs.Observacion]) -> dict:
comprobante = _comprobante(factura)
@@ -225,12 +236,16 @@ def _anotar(codigo: str, estado: str, error: str | None = None, destinatario: st
def responder(token: str, buzon: str, email: dict, plantilla: str, contexto: dict, registro: dict,
sin_comprobante: bool = False) -> str:
sin_comprobante: bool = False, estado_exito: str = "enviada") -> str:
"""
Responde al remitente con la plantilla ``plantilla`` ("aceptado" o "rechazado").
Responde al remitente con la plantilla ``plantilla`` ("aceptado", "rechazado" o "fuera-de-linea").
Args:
estado_exito: Estado que se anota si el envío sale bien (``"en_espera"`` para el aviso
de servicio fuera de línea).
Returns:
``"enviada"``, ``"fallida"`` u ``"omitida"``; también queda anotado en ``comprobantes``.
El estado anotado en ``comprobantes``: ``estado_exito``, ``"fallida"`` u ``"omitida"``.
"""
codigo = registro["codigo_seguimiento"]
remitente = email.get("sender") or ""
@@ -254,7 +269,7 @@ def responder(token: str, buzon: str, email: dict, plantilla: str, contexto: dic
enviado = outlook_utilities.send_email(
token, buzon, para, prefijo + contexto["asunto"], html, None, bcc_recipients=copia_oculta or None,
)
estado = "enviada" if enviado else "fallida"
estado = estado_exito if enviado else "fallida"
log(f"{'✉️ ' if enviado else '❌'} Respuesta {plantilla} {estado} → {', '.join(para)} (modo {modo()})",
severity=logging.INFO if enviado else logging.ERROR)
_anotar(codigo, estado, None if enviado else "Microsoft Graph rechazó el envío (ver log)", ", ".join(para))
+74 -6
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...")
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:
raise ConnectionError(
f"Login fallido: HTTP {resp.status_code} - {resp.text}"
)
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 []
# ============================================================================
+21 -1
View File
@@ -25,7 +25,8 @@ import database_utilities
PREFIJO_CODIGO = "ABR"
ESTADOS = ("pendiente", "aceptado", "rechazado", "error")
ESTADOS_NOTIFICACION = ("pendiente", "enviada", "fallida", "omitida")
# en_espera: se avisó al proveedor que el servicio está fuera de línea y el comprobante se reintentará
ESTADOS_NOTIFICACION = ("pendiente", "enviada", "fallida", "omitida", "en_espera")
# ============================================================================
@@ -190,6 +191,25 @@ def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | No
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
def registrar_intento(codigo_seguimiento: str) -> int:
"""
Suma un intento fallido de procesar el comprobante (SAP fuera de línea) y devuelve el total.
Raises:
LookupError: Si no existe el código.
"""
with _conexion() as conn:
cursor = conn.execute(
"UPDATE comprobantes SET intentos = intentos + 1, actualizado_en = ? WHERE codigo_seguimiento = ?",
(_ahora(), codigo_seguimiento),
)
if cursor.rowcount == 0:
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
return conn.execute(
"SELECT intentos FROM comprobantes WHERE codigo_seguimiento = ?", (codigo_seguimiento,)
).fetchone()[0]
def contar_respuestas_correo(remitente: str, horas: int = 24) -> int:
"""
Cuenta las respuestas ya enviadas en las últimas ``horas`` a un remitente por
+45
View File
@@ -233,3 +233,48 @@ def test_valor_referencial_consulta_una_vez_por_guia(monkeypatch):
assert llamadas == [("T001", "1"), ("T001", "2")]
assert [r["valor_sap"] for r in resultado] == [7.5, 7.5]
# ============================================================================
# Caídas de SAP y errores de consulta
# ============================================================================
def test_validate_prices_deja_pasar_una_caida_de_sap(sap, monkeypatch):
def cae(**k):
raise draft_sap.sapb1_manager.SapNoDisponible("sin conexión")
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", cae)
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
with pytest.raises(draft_sap.sapb1_manager.SapNoDisponible): # no se disfraza de "error de la guía"
draft_sap.validate_prices(factura)
def test_validate_prices_marca_el_error_de_consulta_de_la_guia(sap, monkeypatch):
def falla(**k):
raise ValueError("respuesta rara de SAP")
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", falla)
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
_, resultados = draft_sap.validate_prices(factura)
assert resultados[0]["guias_lt"][0]["error"] == "respuesta rara de SAP"
assert draft_sap.hay_error_de_consulta(resultados) == "respuesta rara de SAP"
def test_hay_error_de_consulta_sin_errores():
assert draft_sap.hay_error_de_consulta([]) is None
assert draft_sap.hay_error_de_consulta([{"guias_lt": [{"guia": {}, "line_total": 0.0}]}, {"guias_lt": []}]) is None
def test_hana_disponible(monkeypatch):
cerrada = []
class Conexion:
def close(self):
cerrada.append(True)
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: Conexion())
assert draft_sap.hana_disponible() is True and cerrada == [True] # abre y cierra: no deja conexiones colgando
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: None)
assert draft_sap.hana_disponible() is False
+8 -1
View File
@@ -85,7 +85,7 @@ def test_motivos_de_correo_sin_comprobante():
def test_todas_las_observaciones_tienen_titulo_y_como_corregir():
muestras = [
obs.precio_no_coincide(1, 1, 2, ["T001-1"], True), obs.guia_no_encontrada("T001-1"),
obs.linea_sin_guia(1, ""), obs.cantidad_cero(1), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
obs.linea_sin_guia(1, ""), obs.cantidad_cero(1), obs.factura_ya_registrada("F002-1"), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
]
for o in muestras:
assert o.titulo and o.como_corregir and o.codigo
@@ -96,3 +96,10 @@ def test_a_dict_es_serializable():
d = obs.guia_no_encontrada("T001-9").a_dict()
assert d == {"codigo": "guia_no_encontrada", "titulo": "No encontramos la guía T001-9",
"filas": [{"etiqueta": "Guía", "valor": "T001-9"}], "como_corregir": d["como_corregir"]}
def test_factura_ya_registrada():
o = obs.factura_ya_registrada("F002-486")
assert o.codigo == "factura_ya_registrada" and o.filas == (("Comprobante", "F002-486"),)
assert "No necesita enviarla otra vez" in o.como_corregir
+177 -7
View File
@@ -38,6 +38,9 @@ class Entorno:
self.sap_lanza = None # excepción que lanza get_delivery_notes
self.error_al_crear = None
self.hana_disponible = True
self.existente = None # dict: la factura ya existe en SAP
self.sap_caido = False # SAP no responde (en la búsqueda de la factura)
self.sap_caido_en_validacion = False
self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos
self.drafts = []
self.correos = []
@@ -45,11 +48,12 @@ class Entorno:
@property
def tecnicos(self):
return [c for c in self.correos if c["asunto"].startswith("Draft SAP B1")]
return [c for c in self.correos if "Draft SAP B1" in c["asunto"]]
@property
def respuestas(self):
return [c for c in self.correos if " · " in c["asunto"] and ("Aceptado" in c["asunto"] or "Rechazado" in c["asunto"])]
return [c for c in self.correos if " · " in c["asunto"]
and any(p in c["asunto"] for p in ("Aceptado", "Rechazado", "En cola"))]
@pytest.fixture
@@ -102,7 +106,18 @@ def entorno(tmp_path, monkeypatch):
monkeypatch.setattr(outlook_utilities, "download_attachments", descargar)
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
monkeypatch.setattr(sapb1_manager, "get_zonas", lambda: [{"Code": "Z1", "Name": "ZONA1"}])
def buscar_existente(ruc, tipo, serie, numero):
if e.sap_caido:
raise sapb1_manager.SapNoDisponible("No se pudo conectar con SAP B1")
return e.existente
def zonas():
if e.sap_caido_en_validacion:
raise sapb1_manager.SapNoDisponible("Timeout en Drafts")
return [{"Code": "Z1", "Name": "ZONA1"}]
monkeypatch.setattr(sapb1_manager, "buscar_factura_existente", buscar_existente)
monkeypatch.setattr(sapb1_manager, "get_zonas", zonas)
monkeypatch.setattr(sapb1_manager, "get_delivery_notes", delivery_notes)
monkeypatch.setattr(sapb1_manager, "obtener_condicion_pago", lambda c: {"NumberOfAdditionalDays": 30})
monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft)
@@ -207,18 +222,20 @@ def test_guia_inexistente_rechaza_con_guia_no_encontrada(entorno, monkeypatch):
assert entorno.drafts == []
def test_guia_inexistente_con_hana_caido_es_error_interno_y_no_se_le_culpa_al_proveedor(entorno, monkeypatch):
def test_guia_inexistente_con_hana_caido_es_servicio_fuera_de_linea_y_no_rechazo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.guia_existe = False
entorno.hana_disponible = False
_procesar(entorno)
assert entorno.respuestas == [] # nada al proveedor
assert entorno.drafts == []
(aviso,) = entorno.respuestas # no se rechaza: se avisa que está en cola
assert aviso["asunto"].startswith("En cola · Factura F002-2450") and "No encontramos la guía" not in aviso["html"]
(tecnico,) = entorno.tecnicos
assert "HANA" in tecnico["html"]
assert "HANA" in tecnico["html"] and tecnico["asunto"].startswith("SAP fuera de línea")
(registro,) = _registros()
assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida"
assert registro["estado"] == "error" and registro["notificacion_estado"] == "en_espera"
def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch):
@@ -413,3 +430,156 @@ def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypat
assert entorno.drafts == [] and entorno.respuestas == []
assert "ZeroDivisionError" in entorno.tecnicos[0]["html"]
assert _registros()[0]["estado"] == "error"
# ============================================================================
# La factura ya existe en SAP
# ============================================================================
def _ids_de_correos_guardados():
conn = sqlite3.connect(database_utilities.DB_NAME)
try:
return [f[0] for f in conn.execute("SELECT id FROM emails")]
finally:
conn.close()
def test_factura_que_ya_existe_en_sap_no_crea_otro_draft_y_se_rechaza(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.existente = {"entidad": "PurchaseInvoices", "DocEntry": 55, "DocNum": 9}
_procesar(entorno)
assert entorno.drafts == []
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER] and respuesta["asunto"].startswith("Rechazado · Factura F002-2450")
assert "Esta factura ya está registrada en nuestro sistema" in respuesta["html"]
(tecnico,) = entorno.tecnicos
assert "ya existe en SAP" in tecnico["html"] and "PurchaseInvoices 55" in tecnico["html"]
assert not any(n.startswith("draft_sapb1") for n in os.listdir(entorno.salida)) # ni se armó el draft
assert _registros()[0]["estado"] == "rechazado"
def test_el_reenvio_de_una_factura_aceptada_no_pisa_el_registro_original(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno) # se acepta: draft 777
entorno.existente = {"entidad": "Drafts", "DocEntry": 777, "DocNum": 3}
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
(registro,) = _registros()
assert (registro["estado"], registro["doc_entry"], registro["veces_recibido"]) == ("aceptado", 777, 2)
assert len(entorno.drafts) == 1 # no se creó un segundo draft
assert "ya está registrada" in entorno.respuestas[-1]["html"]
def test_reprocesar_el_mismo_correo_ya_resuelto_no_repite_nada(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno)
enviados = len(entorno.correos)
_procesar(entorno) # mismo correo otra vez
assert len(entorno.drafts) == 1 and len(entorno.correos) == enviados
# ============================================================================
# SAP fuera de línea
# ============================================================================
def test_sap_fuera_de_linea_avisa_al_proveedor_y_deja_el_correo_en_cola(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
_procesar(entorno)
assert entorno.drafts == []
(aviso,) = entorno.respuestas
assert aviso["para"] == [SENDER] and aviso["asunto"].startswith("En cola · Factura F002-2450 · ABR-")
assert "Recibimos su factura" in aviso["html"] and "temporalmente fuera de línea" in aviso["html"]
assert "No necesita reenviarla" in aviso["html"] and "{{" not in aviso["html"]
(tecnico,) = entorno.tecnicos
assert tecnico["para"] == EQUIPO and tecnico["asunto"].startswith("SAP fuera de línea - Draft SAP B1")
(registro,) = _registros()
assert (registro["estado"], registro["notificacion_estado"], registro["intentos"]) == ("error", "en_espera", 1)
(obs,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
assert obs["codigo"] == "servicio_no_disponible" and obs["intentos"] == 1
assert "abc" not in _ids_de_correos_guardados() # olvidado: el próximo ciclo lo vuelve a tomar
def test_los_reintentos_no_repiten_el_aviso_y_al_volver_sap_se_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
_procesar(entorno)
_procesar(entorno) # el ciclo siguiente: SAP sigue caído
assert len(entorno.respuestas) == 1 and len(entorno.tecnicos) == 1 # un solo aviso, no uno por ciclo
assert _registros()[0]["intentos"] == 2 and "abc" not in _ids_de_correos_guardados()
entorno.sap_caido = False
_procesar(entorno) # SAP volvió
asuntos = [c["asunto"] for c in entorno.respuestas]
assert asuntos[0].startswith("En cola") and asuntos[1].startswith("Aceptado") and len(asuntos) == 2
(registro,) = _registros()
assert (registro["estado"], registro["doc_entry"], registro["notificacion_estado"]) == ("aceptado", 777, "enviada")
assert len(entorno.drafts) == 1
assert "abc" in _ids_de_correos_guardados() # ya resuelto: no se vuelve a tomar
def test_se_agotan_los_reintentos_y_se_avisa_al_equipo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
monkeypatch.setattr(pipeline, "MAX_REINTENTOS", 2)
entorno.sap_caido = True
_procesar(entorno)
_procesar(entorno)
assert [c["asunto"].split(" - ")[0] for c in entorno.tecnicos] == ["SAP fuera de línea", "Reintentos agotados"]
assert len(entorno.respuestas) == 1 # el proveedor solo recibió el aviso inicial
assert "abc" in _ids_de_correos_guardados() # deja de reintentarse
registro = _registros()[0]
assert registro["notificacion_estado"] == "omitida" and "reintentos" in registro["notificacion_error"]
@pytest.mark.parametrize("como_falla", ["validacion", "crear_draft"])
def test_la_caida_en_cualquier_paso_es_fuera_de_linea(entorno, monkeypatch, como_falla):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
if como_falla == "validacion":
entorno.sap_caido_en_validacion = True
else:
entorno.error_al_crear = sapb1_manager.SapNoDisponible("HTTP 503 - Service Unavailable")
_procesar(entorno)
assert entorno.drafts == []
(aviso,) = entorno.respuestas
assert aviso["asunto"].startswith("En cola · Factura F002-2450")
assert "abc" not in _ids_de_correos_guardados()
def test_un_rechazo_normal_de_sap_no_es_una_caida(entorno, monkeypatch):
"""HTTP 400 al crear el draft: es un problema del dato o de SAP, no se reintenta ni se le dice nada al proveedor."""
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.error_al_crear = ConnectionError("Error en SAP POST Drafts: HTTP 400 - ItemCode inexistente")
_procesar(entorno)
assert entorno.respuestas == []
assert "HTTP 400" in entorno.tecnicos[0]["html"]
assert _registros()[0]["estado"] == "error" and "abc" in _ids_de_correos_guardados()
def test_sin_seguimiento_una_caida_no_se_reintenta(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
def roto(*a, **k):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(seguimiento, "registrar_comprobante", roto)
_procesar(entorno)
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
assert "abc" in _ids_de_correos_guardados() # sin la cuenta de intentos no se arriesga un bucle
+10 -1
View File
@@ -114,7 +114,7 @@ def test_todos_los_escenarios_se_renderizan_completos(escenario):
assert html.lstrip().startswith("<!DOCTYPE html>")
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado"])
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado", "fuera-de-linea"])
def test_los_ejemplos_cubren_las_variables_de_la_plantilla(nombre):
"""Si la plantilla pide algo nuevo (o un ejemplo sobra), el desfase se nota aquí."""
usados = plantillas.nombres_usados(plantillas.cargar_plantilla(nombre))
@@ -177,3 +177,12 @@ def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante():
assert "Guías revisadas" in html and "RUC 20537929520" in html and "Comprobante</td>" in html
def test_fuera_de_linea_dice_que_quedo_en_cola_y_no_pide_reenviar():
html = plantillas.renderizar_plantilla("fuera-de-linea", _ejemplos()["fuera_de_linea"]["contexto"])
assert "EN COLA" in html and "Recibimos su factura" in html
assert "temporalmente fuera de línea" in html and "No necesita reenviarla" in html
assert "Guías revisadas" not in html and "Pago estimado" not in html and "ACEPTADO" not in html
assert "ABR-261019-00048" in html and "{{" not in html
+25
View File
@@ -268,3 +268,28 @@ def test_encabezado_tecnico(registro):
def test_enviar_a_equipo(graph):
assert rp.enviar_a_equipo("t", BUZON, "Asunto", "<p/>", ["a.xml"]) is True
assert graph[0]["para"] == ["eq@aberio.com"]
def test_contexto_fuera_de_linea_renderiza(registro):
contexto = rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA)
assert contexto["asunto"] == f"En cola · Factura F002-486 · {registro['codigo_seguimiento']}"
html = plantillas.renderizar_plantilla("fuera-de-linea", contexto)
assert "{{" not in html and "SÁNCHEZ &amp; HIJOS" in html
def test_responder_con_estado_de_exito_en_espera(graph, registro, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
estado = rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
registro, estado_exito="en_espera")
assert estado == "en_espera" and graph[0]["para"] == ["prov@empresa.com"]
assert seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
def test_si_el_aviso_falla_queda_fallida_aunque_el_exito_sea_en_espera(graph, registro):
graph.ok = False
assert rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
registro, estado_exito="en_espera") == "fallida"
+185
View File
@@ -0,0 +1,185 @@
import pytest
import requests
import hana_manager
import sapb1_manager as s
class _Resp:
def __init__(self, status=200, body=None, text=""):
self.status_code, self._body, self.text = status, body if body is not None else {}, text
self.content = b"x"
self.cookies = {}
def json(self):
return self._body
@pytest.fixture(autouse=True)
def sesion_abierta(monkeypatch):
monkeypatch.setitem(s._sap_session, "headers", {"Cookie": "B1SESSION=x"})
# ============================================================================
# Clasificación de errores: caída vs rechazo de SAP
# ============================================================================
@pytest.mark.parametrize("error", [requests.exceptions.ConnectTimeout("t"), requests.exceptions.ReadTimeout("t"),
requests.exceptions.ConnectionError("c")])
def test_timeout_o_sin_conexion_es_sap_no_disponible(monkeypatch, error):
def falla(**k):
raise error
monkeypatch.setattr(s.requests, "request", lambda **k: falla(**k))
with pytest.raises(s.SapNoDisponible):
s.forward_to_sapb1("GET", "Drafts")
@pytest.mark.parametrize("status", [500, 502, 503])
def test_http_5xx_es_sap_no_disponible(monkeypatch, status):
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(status, {"error": {"message": {"value": "caído"}}}))
with pytest.raises(s.SapNoDisponible, match=f"HTTP {status}"):
s.forward_to_sapb1("GET", "Drafts")
def test_http_4xx_es_un_rechazo_no_una_caida(monkeypatch):
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(400, {"error": {"message": {"value": "ItemCode inexistente"}}}))
with pytest.raises(ConnectionError, match="ItemCode inexistente") as ex:
s.forward_to_sapb1("POST", "Drafts", json_data={})
assert not isinstance(ex.value, s.SapNoDisponible)
def test_sap_no_disponible_sigue_siendo_connection_error():
assert issubclass(s.SapNoDisponible, ConnectionError)
def test_login_sin_conexion_es_caida(monkeypatch):
def falla(*a, **k):
raise requests.exceptions.ConnectTimeout("t")
monkeypatch.setattr(s.requests, "post", falla)
with pytest.raises(s.SapNoDisponible):
s.login()
def test_login_5xx_es_caida_pero_credenciales_malas_no(monkeypatch):
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(503, text="down"))
with pytest.raises(s.SapNoDisponible):
s.login()
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(401, text="Invalid credentials"))
with pytest.raises(ConnectionError) as ex:
s.login()
assert not isinstance(ex.value, s.SapNoDisponible)
# ============================================================================
# Guías: una caída no se confunde con "no existe"
# ============================================================================
def _forward(respuestas):
"""forward_to_sapb1 simulado: ``respuestas`` es una función (metodo, entidad) -> dict | excepción."""
def forward(method, endpoint_path, **k):
r = respuestas(method, endpoint_path)
if isinstance(r, Exception):
raise r
return _Resp(200, r)
return forward
def test_sl_caido_y_hana_sin_resultados_es_caida_no_guia_inexistente(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
with pytest.raises(s.SapNoDisponible):
s.get_delivery_notes("09", "T001", "1")
def test_sl_caido_pero_hana_encuentra_la_guia_sigue_adelante(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [{"DocEntry": 7, "Tabla": "ODLN"}])
entradas = s._get_delivery_entries("09", "T001", "1")
assert entradas == [{"DocEntry": 7, "Tabla": "ODLN", "Entidad": "DeliveryNotes"}]
def test_guia_que_de_verdad_no_existe_devuelve_lista_vacia(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: {"value": []}))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
assert s.get_delivery_notes("09", "T001", "1") == []
def test_un_rechazo_normal_de_sl_con_hana_vacio_no_es_caida(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: ConnectionError("HTTP 400 - mal filtro")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
assert s.get_delivery_notes("09", "T001", "1") == []
def test_una_caida_al_leer_el_documento_se_propaga(monkeypatch):
def respuestas(metodo, entidad):
if entidad == "DeliveryNotes":
return {"value": [{"DocEntry": 7}]}
return s.SapNoDisponible("se cayó a mitad") # GET DeliveryNotes(7)
monkeypatch.setattr(s, "forward_to_sapb1", _forward(respuestas))
with pytest.raises(s.SapNoDisponible):
s.get_delivery_notes("09", "T001", "1")
# ============================================================================
# buscar_factura_existente
# ============================================================================
class _Grabadora:
def __init__(self, existentes):
self.llamadas, self.existentes = [], existentes
def __call__(self, method, endpoint_path, **k):
self.llamadas.append((endpoint_path, k["params"]))
valor = self.existentes.get(endpoint_path)
if isinstance(valor, Exception):
raise valor
return _Resp(200, {"value": [valor] if valor else []})
def test_encuentra_la_factura_de_compra(monkeypatch):
graba = _Grabadora({"PurchaseInvoices": {"DocEntry": 10, "DocNum": 5}})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") == \
{"entidad": "PurchaseInvoices", "DocEntry": 10, "DocNum": 5}
entidad, params = graba.llamadas[0]
assert len(graba.llamadas) == 1 # no hace falta mirar los drafts
for trozo in ("CardCode eq 'PL20537929520'", "U_BPP_MDTD eq '01'", "U_BPP_MDSD eq 'F002'",
"U_BPP_MDCD eq '486'", "Cancelled eq 'tNO'"):
assert trozo in params["$filter"]
def test_si_no_es_factura_busca_en_los_drafts(monkeypatch):
graba = _Grabadora({"Drafts": {"DocEntry": 3, "DocNum": 3}})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
assert s.buscar_factura_existente("20537929520", "01", "F002", "486")["entidad"] == "Drafts"
assert [l[0] for l in graba.llamadas] == ["PurchaseInvoices", "Drafts"]
assert "DocObjectCode eq 'oPurchaseInvoices'" in graba.llamadas[1][1]["$filter"]
def test_si_no_existe_devuelve_none(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({}))
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") is None
def test_las_comillas_se_escapan_en_el_filtro(monkeypatch):
graba = _Grabadora({})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
s.buscar_factura_existente("20537929520", "01", "F0'02", "4' or '1'='1")
filtro = graba.llamadas[0][1]["$filter"]
assert "U_BPP_MDSD eq 'F0''02'" in filtro and "U_BPP_MDCD eq '4'' or ''1''=''1'" in filtro
def test_una_caida_al_buscar_se_propaga(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({"PurchaseInvoices": s.SapNoDisponible("caído")}))
with pytest.raises(s.SapNoDisponible):
s.buscar_factura_existente("20537929520", "01", "F002", "486")
+42
View File
@@ -210,3 +210,45 @@ def test_init_db_agrega_la_columna_a_una_tabla_creada_antes():
seguimiento.registrar_notificacion("ABR-261001-00001", "enviada", destinatario="a@x.com")
assert seguimiento.obtener("ABR-261001-00001")["notificacion_destinatario"] == "a@x.com"
def test_registrar_intento_cuenta_de_uno_en_uno():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
assert r["intentos"] == 0
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 1
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 2
assert seguimiento.obtener(r["codigo_seguimiento"])["intentos"] == 2
def test_registrar_intento_de_un_codigo_inexistente():
with pytest.raises(LookupError):
seguimiento.registrar_intento("ABR-000000-99999")
def test_en_espera_es_un_estado_de_notificacion_valido():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
seguimiento.registrar_notificacion(r["codigo_seguimiento"], "en_espera", destinatario="a@x.com")
assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
def test_init_db_agrega_la_columna_intentos_a_una_tabla_anterior():
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.executescript("""
DROP TABLE comprobantes;
CREATE TABLE comprobantes (
id INTEGER PRIMARY KEY AUTOINCREMENT, codigo_seguimiento TEXT UNIQUE, email_id TEXT NOT NULL,
ruc_emisor TEXT, tipo TEXT, serie TEXT, numero TEXT, estado TEXT NOT NULL DEFAULT 'pendiente',
observaciones TEXT, doc_entry INTEGER, veces_recibido INTEGER NOT NULL DEFAULT 1,
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente', notificacion_error TEXT, notificacion_fecha TEXT,
notificacion_destinatario TEXT, creado_en TEXT NOT NULL, actualizado_en TEXT NOT NULL);
INSERT INTO comprobantes (codigo_seguimiento, email_id, creado_en, actualizado_en)
VALUES ('ABR-261001-00001', 'viejo', '2026-10-01 10:00:00', '2026-10-01 10:00:00');
""")
conn.commit()
conn.close()
database_utilities.init_db()
assert seguimiento.obtener("ABR-261001-00001")["intentos"] == 0 # el valor por defecto se aplica a lo existente
assert seguimiento.registrar_intento("ABR-261001-00001") == 1