From f0ad6aa78ea585206efa18a37e42f52db2c7c6e3 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 22 Oct 2026 12:08:38 -0500 Subject: [PATCH] =?UTF-8?q?se=20valida=20si=20la=20factura=20ya=20existe?= =?UTF-8?q?=20en=20SAP=20y=20se=20avisa=20cuando=20SAP=20est=C3=A1=20fuera?= =?UTF-8?q?=20de=20l=C3=ADnea?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- database_utilities.py | 14 +- docs/pipeline.md | 17 +- docs/respuesta_proveedor.md | 33 ++- docs/seguimiento.md | 9 +- draft_sap.py | 2 + notificaciones.py | 6 +- observaciones.py | 8 + pipeline.py | 265 ++++++++++++++++-------- plantilla/ejemplos.json | 17 ++ plantilla/plantilla-fuera-de-linea.html | 112 ++++++++++ respuesta_proveedor.py | 23 +- sapb1_manager.py | 92 ++++++-- seguimiento.py | 22 +- tests/test_draft_sap.py | 45 ++++ tests/test_observaciones.py | 9 +- tests/test_pipeline_flujo.py | 184 +++++++++++++++- tests/test_plantillas.py | 11 +- tests/test_respuesta_proveedor.py | 25 +++ tests/test_sapb1_manager.py | 185 +++++++++++++++++ tests/test_seguimiento.py | 42 ++++ 20 files changed, 991 insertions(+), 130 deletions(-) create mode 100644 plantilla/plantilla-fuera-de-linea.html create mode 100644 tests/test_sapb1_manager.py diff --git a/database_utilities.py b/database_utilities.py index 01f49dc..0ac8e83 100644 --- a/database_utilities.py +++ b/database_utilities.py @@ -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 diff --git a/docs/pipeline.md b/docs/pipeline.md index 53bf07b..9f98236 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -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
u otro error?} + T -- Caída --> FL[estado fuera de línea:
aviso 'En cola' una vez
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
+ respuesta con plantilla al remitente] V --> W X --> W2[16. Correo técnico al equipo
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. diff --git a/docs/respuesta_proveedor.md b/docs/respuesta_proveedor.md index 714a3d2..da6476c 100644 --- a/docs/respuesta_proveedor.md +++ b/docs/respuesta_proveedor.md @@ -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. diff --git a/docs/seguimiento.md b/docs/seguimiento.md index 289cbf4..e6d6f02 100644 --- a/docs/seguimiento.md +++ b/docs/seguimiento.md @@ -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`. diff --git a/draft_sap.py b/draft_sap.py index 70b2fae..1cc331a 100644 --- a/draft_sap.py +++ b/draft_sap.py @@ -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)}) diff --git a/notificaciones.py b/notificaciones.py index 1a863d1..de0d6bb 100644 --- a/notificaciones.py +++ b/notificaciones.py @@ -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"

Valor Referencial (XML vs SAP)

{valor_referencial_table}" if status == "created": return f"

✅ Draft SAP B1 creado exitosamente.

DocEntry: {kwargs.get('doc_entry')}

{base}

Validación de precios

{validation_table}{valref_section}" + elif status == "duplicate": + existente = kwargs.get("existente") or {} + return f"

⏭️ Draft NO creado: la factura ya existe en SAP ({existente.get('entidad')} {existente.get('DocEntry')}).

{base}" elif status == "error": return f"

❌ Error al crear Draft SAP B1.

{base}

Error: {kwargs.get('error')}

Validación de precios

{validation_table}{valref_section}" else: diff --git a/observaciones.py b/observaciones.py index 9505a2f..d1f43db 100644 --- a/observaciones.py +++ b/observaciones.py @@ -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)),), diff --git a/pipeline.py b/pipeline.py index 648ef05..86355e8 100644 --- a/pipeline.py +++ b/pipeline.py @@ -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) # ============================================================================ diff --git a/plantilla/ejemplos.json b/plantilla/ejemplos.json index 4ef7afe..be8fad7 100644 --- a/plantilla/ejemplos.json +++ b/plantilla/ejemplos.json @@ -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" + } } } \ No newline at end of file diff --git a/plantilla/plantilla-fuera-de-linea.html b/plantilla/plantilla-fuera-de-linea.html new file mode 100644 index 0000000..0452e5e --- /dev/null +++ b/plantilla/plantilla-fuera-de-linea.html @@ -0,0 +1,112 @@ + + + + + + + + + + +{{asunto}} + + + + +
{{preheader}} ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏
+ + +
+ + + + + + + + + + + + +
+ + diff --git a/respuesta_proveedor.py b/respuesta_proveedor.py index 5f5efc6..7e516ca 100644 --- a/respuesta_proveedor.py +++ b/respuesta_proveedor.py @@ -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)) diff --git a/sapb1_manager.py b/sapb1_manager.py index f234d3b..5ca9c79 100644 --- a/sapb1_manager.py +++ b/sapb1_manager.py @@ -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 [] # ============================================================================ diff --git a/seguimiento.py b/seguimiento.py index 6541e9c..7cfcafe 100644 --- a/seguimiento.py +++ b/seguimiento.py @@ -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 diff --git a/tests/test_draft_sap.py b/tests/test_draft_sap.py index b6a0247..d25a807 100644 --- a/tests/test_draft_sap.py +++ b/tests/test_draft_sap.py @@ -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 + diff --git a/tests/test_observaciones.py b/tests/test_observaciones.py index 9fb1dbf..e777658 100644 --- a/tests/test_observaciones.py +++ b/tests/test_observaciones.py @@ -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 + diff --git a/tests/test_pipeline_flujo.py b/tests/test_pipeline_flujo.py index 183b442..1f2a818 100644 --- a/tests/test_pipeline_flujo.py +++ b/tests/test_pipeline_flujo.py @@ -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 diff --git a/tests/test_plantillas.py b/tests/test_plantillas.py index 48af5fd..29ba8bd 100644 --- a/tests/test_plantillas.py +++ b/tests/test_plantillas.py @@ -114,7 +114,7 @@ def test_todos_los_escenarios_se_renderizan_completos(escenario): assert html.lstrip().startswith("") -@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" 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 + diff --git a/tests/test_respuesta_proveedor.py b/tests/test_respuesta_proveedor.py index 70ac9a5..ba33eb4 100644 --- a/tests/test_respuesta_proveedor.py +++ b/tests/test_respuesta_proveedor.py @@ -268,3 +268,28 @@ def test_encabezado_tecnico(registro): def test_enviar_a_equipo(graph): assert rp.enviar_a_equipo("t", BUZON, "Asunto", "

", ["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 & 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" + diff --git a/tests/test_sapb1_manager.py b/tests/test_sapb1_manager.py new file mode 100644 index 0000000..0751a08 --- /dev/null +++ b/tests/test_sapb1_manager.py @@ -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") diff --git a/tests/test_seguimiento.py b/tests/test_seguimiento.py index 182fcce..83e8de0 100644 --- a/tests/test_seguimiento.py +++ b/tests/test_seguimiento.py @@ -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 +