From 4deb9d4e4d49d2bdbc514ecaa14a9a35f9f4720f Mon Sep 17 00:00:00 2001 From: George Date: Thu, 22 Oct 2026 08:55:54 -0500 Subject: [PATCH] se responde al remitente con las plantillas de aceptado/rechazado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El sistema contesta a quien envió el correo con la plantilla que corresponda. Por defecto (NOTIFICACION_MODO=prueba) la respuesta va solo al equipo con el asunto "[PRUEBA → remitente]"; en modo real va al remitente con copia oculta al equipo. El correo técnico (tablas de SAP, JSON) pasa a ir solo al equipo. - respuesta_proveedor.py: contextos de las plantillas, envío y protecciones (remitente desconocido o sin dominio, propio buzón, no-reply, mensajes automáticos, respuestas a notificaciones, límite de 3 por remitente/24 h). - observaciones.py: motivos de rechazo con título, filas y cómo corregir (borrador de textos); también para correos sin comprobante válido. - Un error de SAP/HANA o un fallo inesperado no se le comunica al proveedor y ya no tumba el ciclo; "guía no encontrada" exige que HANA responda. - outlook_utilities: es_mensaje_automatico y copia oculta en send_email. - seguimiento: notificacion_destinatario (con migración) y contar_respuestas_correo. - config_validator anuncia el modo activo; documentación y pruebas. Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 12 + README.md | 8 +- config_validator.py | 15 ++ database_utilities.py | 6 + docs/inventario.md | 17 +- docs/outlook_utilities.md | 8 +- docs/pipeline.md | 44 +++- docs/respuesta_proveedor.md | 92 ++++++++ docs/seguimiento.md | 15 +- draft_sap.py | 31 ++- observaciones.py | 157 ++++++++++++++ outlook_utilities.py | 33 ++- pipeline.py | 136 +++++++++--- respuesta_proveedor.py | 285 ++++++++++++++++++++++++ seguimiento.py | 27 ++- tests/conftest.py | 11 + tests/test_config_validator.py | 32 ++- tests/test_observaciones.py | 98 +++++++++ tests/test_outlook.py | 97 +++++++++ tests/test_pipeline_flujo.py | 349 +++++++++++++++++++++++------- tests/test_respuesta_proveedor.py | 270 +++++++++++++++++++++++ tests/test_seguimiento.py | 63 ++++++ 22 files changed, 1660 insertions(+), 146 deletions(-) create mode 100644 docs/respuesta_proveedor.md create mode 100644 observaciones.py create mode 100644 respuesta_proveedor.py create mode 100644 tests/test_observaciones.py create mode 100644 tests/test_outlook.py create mode 100644 tests/test_respuesta_proveedor.py diff --git a/.env.example b/.env.example index 1a9cc72..f2cf8b1 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,7 @@ HANA_PASSWORD = HANA_SCHEMA = # --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) --- +# Cómo obtener estos valores: docs/configurar_msal.md CLIENT_ID = CLIENT_SECRET = TENANT_ID = @@ -33,3 +34,14 @@ VERIFY_SSL = false # --- Logging (ver logger_manager.py). Opcional, por defecto INFO --- LOG_LEVEL = INFO + +# --- Respuesta al proveedor (aceptado / rechazado), ver docs/respuesta_proveedor.md --- +# prueba (por defecto): la respuesta va al equipo con asunto "[PRUEBA → remitente]", NO al proveedor. +# real: la respuesta va a quien envió el correo, con copia oculta al equipo. +NOTIFICACION_MODO = prueba +# Direcciones internas (coma o punto y coma): reciben el correo técnico y las respuestas en modo prueba. +NOTIFICACION_EQUIPO = equipo1@dominio.com,equipo2@dominio.com +# Contacto del botón "Escribir a Aberio" (por defecto, el buzón EMAIL). +NOTIFICACION_CONTACTO = +# URL pública (https) del logo que se muestra en el correo. Vacío = sin logo. +LOGO_URL = diff --git a/README.md b/README.md index 862c6dc..f3fac2f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Bandeja de entrada (Microsoft Graph) → extrae datos de PDF y XML y asigna las guías de remisión por línea → valida precios de cada guía contra SAP (Service Layer, con fallback a HANA) → crea el Draft en SAP B1 si todos los precios coinciden - → envía correo con el resultado (Draft creado / error / precios no coinciden) + → responde a quien envió el correo con la plantilla de aceptado o rechazado + (por defecto en modo prueba: la respuesta va al equipo; ver docs/respuesta_proveedor.md) ``` Cada correo se registra en una base SQLite (`emails.db`) y no se vuelve a procesar. Detalle del flujo en [`docs/pipeline.md`](docs/pipeline.md). @@ -70,7 +71,8 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e | `main.py` | Demonio y horarios | | `pipeline.py` | Orquesta el proceso de cada correo | | `adjuntos.py`, `documentos.py`, `persistencia.py` | Descarga de adjuntos, extracción de PDF/XML, guardado de JSON | -| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; cuerpo de los correos | +| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; correo técnico para el equipo | +| `respuesta_proveedor.py`, `observaciones.py`, `plantillas.py` | Respuesta de aceptado/rechazado al remitente con las plantillas HTML (`plantilla/`) | | `outlook_utilities.py`, `sapb1_manager.py`, `hana_manager.py` | Clientes de Microsoft Graph, SAP Service Layer y SAP HANA | | `sunat_schema.py`, `pdf_utilities.py` | Lectura de XML SUNAT y de PDF | | `database_utilities.py`, `seguimiento.py`, `email_utilities.py` | SQLite local; códigos de seguimiento y resultado por comprobante; ZIP y filtrado de adjuntos | @@ -88,6 +90,8 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e | [`docs/procesar_pdfs.md`](docs/procesar_pdfs.md) / [`docs/procesar_xmls.md`](docs/procesar_xmls.md) | Extracción de datos de PDF y XML | | [`docs/validar_precios.md`](docs/validar_precios.md) | Validación de precios contra SAP | | [`docs/construir_draft.md`](docs/construir_draft.md) | Construcción del Draft SAP B1 | +| [`docs/respuesta_proveedor.md`](docs/respuesta_proveedor.md) | Respuesta de aceptado/rechazado al proveedor: modos, protecciones y observaciones | | [`docs/seguimiento.md`](docs/seguimiento.md) | Códigos de seguimiento y resultado por comprobante | +| [`docs/configurar_msal.md`](docs/configurar_msal.md) | Cómo obtener `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET` en Azure | | [`docs/outlook_utilities.md`](docs/outlook_utilities.md) | Cliente de Microsoft Graph | | [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA | diff --git a/config_validator.py b/config_validator.py index 7edc2dc..26b786b 100644 --- a/config_validator.py +++ b/config_validator.py @@ -21,6 +21,9 @@ OBLIGATORIAS = { "SAP HANA": ["HANA_SERVER", "HANA_USER", "HANA_PASSWORD", "HANA_SCHEMA"], } +# Modos de ``NOTIFICACION_MODO``: ver respuesta_proveedor.py +MODOS_NOTIFICACION = ("prueba", "real") + # Variable de verificación SSL → servicio al que aplica. Cada módulo solo # la activa con el valor "true" (cualquier otro valor, o ausente, la desactiva). VERIFICACION_SSL = { @@ -55,6 +58,18 @@ def validar_configuracion() -> tuple[list[str], list[str]]: "y U_VS_ANEXO_PDF/XML irán vacíos en el draft" ) + modo = os.getenv("NOTIFICACION_MODO", "").strip().lower() + if modo and modo not in MODOS_NOTIFICACION: + advertencias.append(f"NOTIFICACION_MODO='{modo}' no es válido (usa {' o '.join(MODOS_NOTIFICACION)}); se usará prueba") + elif modo == "real": + advertencias.append("Modo de notificación REAL: las respuestas se envían a quien envía el correo") + else: + advertencias.append("Modo de notificación PRUEBA: las respuestas van al equipo, no a quien envía el correo") + if not os.getenv("NOTIFICACION_EQUIPO", "").strip(): + advertencias.append("NOTIFICACION_EQUIPO no definida: se usa la lista por defecto del equipo") + if not os.getenv("LOGO_URL", "").strip(): + advertencias.append("LOGO_URL no definida: los correos al proveedor saldrán sin logo") + nivel = os.getenv("LOG_LEVEL", "").strip() if nivel and not isinstance(getattr(logging, nivel.upper(), None), int): advertencias.append(f"LOG_LEVEL='{nivel}' no es un nivel válido; se usará INFO") diff --git a/database_utilities.py b/database_utilities.py index 2ec14b4..01f49dc 100644 --- a/database_utilities.py +++ b/database_utilities.py @@ -47,10 +47,16 @@ def init_db() -> None: 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 ) ''') + # 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") + cursor.execute(''' CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento ON comprobantes (ruc_emisor, tipo, serie, numero) WHERE serie IS NOT NULL diff --git a/docs/inventario.md b/docs/inventario.md index d7b9f7a..5ad6ebc 100644 --- a/docs/inventario.md +++ b/docs/inventario.md @@ -5,27 +5,30 @@ | Archivo | Líneas | Rol | |---------|--------|-----| | `main.py` | 137 | Demonio orquestador (loop de horarios) | -| `pipeline.py` | 272 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) | +| `pipeline.py` | 344 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) | | `pipeline_utils.py` | 27 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` | | `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML | | `documentos.py` | 372 | Extracción PDF/XML, asignación de guías, copia a red | | `persistencia.py` | 163 | Guardado de JSON de resultados y de drafts | -| `draft_sap.py` | 414 | Validación de precios contra SAP, valor referencial y construcción del draft | +| `draft_sap.py` | 442 | Validación de precios contra SAP, valor referencial y construcción del draft | | `notificaciones.py` | 117 | Cuerpos HTML de los correos de notificación | | `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) | | `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) | | `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer | -| `outlook_utilities.py` | 249 | Comunicación con Microsoft Graph | +| `outlook_utilities.py` | 274 | Comunicación con Microsoft Graph | | `hana_manager.py` | 141 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) | -| `database_utilities.py` | 150 | SQLite local (cache de correos) | +| `database_utilities.py` | 156 | SQLite local (cache de correos) | | `tools/*_prueba.py`, `tools/completar_guias_pipeline.py`, `tools/explorar_notes_xml.py` | — | Scripts manuales sobre `tests/fixtures/correos_prueba/` (anteriores a la suite pytest) | | `tools/consulta_rapida.py` | 120 | Script auxiliar para reprocesar correos por ID (sets en `tools/ids_prueba.json`) | -| `seguimiento.py` | 187 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) | -| `config_validator.py` | 83 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) | +| `seguimiento.py` | 206 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) | +| `respuesta_proveedor.py` | 285 | Respuesta de aceptado/rechazado al remitente (modo prueba/real, protecciones, contextos) | +| `observaciones.py` | 157 | Catálogo de motivos de rechazo y sus textos | +| `plantillas.py` | 123 | Renderizador de las plantillas HTML de correo | +| `config_validator.py` | 98 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) | | `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) | | `logger_manager.py` | 150 | Logging a archivo rotado (30 días) + consola + captura de `print()` | -Documentación por módulo: `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md). +Documentación por módulo: `respuesta_proveedor.py`/`observaciones.py`/`plantillas.py` → [respuesta_proveedor.md](respuesta_proveedor.md), `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md). --- diff --git a/docs/outlook_utilities.md b/docs/outlook_utilities.md index 2d986c4..8b0d823 100644 --- a/docs/outlook_utilities.md +++ b/docs/outlook_utilities.md @@ -1,6 +1,6 @@ # outlook_utilities.py — Microsoft Graph (Outlook) -Cliente mínimo de Microsoft Graph para leer la bandeja de entrada, bajar adjuntos y enviar las notificaciones. Autenticación con MSAL (client credentials, permisos de aplicación). +Cliente mínimo de Microsoft Graph para leer la bandeja de entrada, bajar adjuntos y enviar las notificaciones. Autenticación con MSAL (client credentials, permisos de aplicación). Cómo obtener las credenciales: [configurar_msal.md](configurar_msal.md). ## Estructura @@ -19,9 +19,9 @@ outlook_utilities.py | Función | Endpoint Graph | Qué hace | |---------|----------------|----------| | `get_access_token` | `login.microsoftonline.com/{TENANT_ID}` (scope `.default`) | Obtiene el token de aplicación. Lanza `Exception` si MSAL devuelve error. | -| `get_emails` | `GET /users/{user}/mailFolders/Inbox/messages` | Correos de la bandeja de entrada del rango, con paginación (`$top=50`, sigue `@odata.nextLink`). Devuelve `id`, `fecha`, `sender`, `subject`, `has_attach`. Lanza `Exception` si el status no es 200. | +| `get_emails` | `GET /users/{user}/mailFolders/Inbox/messages` | Correos de la bandeja de entrada del rango, con paginación (`$top=50`, sigue `@odata.nextLink`). Devuelve `id`, `fecha`, `sender`, `subject`, `has_attach` y `es_automatico` (calculado con los encabezados del mensaje). Lanza `Exception` si el status no es 200. | | `download_attachments` | `GET /users/{user}/messages/{id}/attachments` | Guarda en `download_path` los adjuntos tipo archivo y devuelve sus nombres. Crea la carpeta si no existe. | -| `send_email` | `POST /users/{from}/sendMail` | Envía HTML con adjuntos y CC; guarda copia en Enviados. Devuelve `True` si Graph responde 202; `False` (con `ERROR` en el log) en cualquier otro caso. No lanza excepciones. | +| `send_email` | `POST /users/{from}/sendMail` | Envía HTML con adjuntos, CC y copia oculta (`bcc_recipients`); guarda copia en Enviados. Devuelve `True` si Graph responde 202; `False` (con `ERROR` en el log) en cualquier otro caso. No lanza excepciones. | ## Zona horaria @@ -38,3 +38,5 @@ Los adjuntos se leen de las rutas indicadas. Una ruta inexistente se omite con u - `VERIFY_SSL` se respeta en `get_access_token`, `get_emails` y `download_attachments`, pero `send_email` tiene `verify_ssl = False` fijo y lo ignora. - El módulo ejecuta `warnings.filterwarnings("ignore")`, lo que silencia todas las advertencias de Python del proceso (incluidas las de certificado SSL). - `get_access_token` retorna `None` (sin lanzar error) si MSAL devuelve un resultado vacío. +- `es_mensaje_automatico(headers)` marca como automáticos los mensajes con `Auto-Submitted` distinto de `no`, `X-Auto-Response-Suppress`, `X-Autoreply`, `Precedence` (`bulk`, `junk`, `list`, `auto_reply`) o `Content-Type` de informe de entrega (rebotes). El sistema no responde a esos mensajes. + diff --git a/docs/pipeline.md b/docs/pipeline.md index 5c88b5c..53bf07b 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -14,7 +14,10 @@ Módulos que orquesta _procesar_un_correo(): ├── documentos.py procesar_pdfs, procesar_xmls, completar_guias, validar_guias, copiar_a_red ├── persistencia.py guardar_resultado, guardar_draft_json ├── draft_sap.py validate_prices, obtener_valor_referencial_sap, build_sap_draft -├── notificaciones.py build_validation_html, build_valor_referencial_html, build_notification_body +├── notificaciones.py build_validation_html, build_valor_referencial_html, build_notification_body (correo técnico) +├── observaciones.py motivos de rechazo con sus textos +├── respuesta_proveedor.py respuesta con plantilla al remitente (modo prueba/real, protecciones) +├── plantillas.py renderizador de las plantillas HTML └── pipeline_utils.py log, ruta_carpeta ``` @@ -54,29 +57,34 @@ flowchart TD F -- Sí --> G[4. _descomprimir ZIPs] G --> H[5. filtrar_y_limpiar] H --> I{Hay PDF/XML?} - I -- No --> C + I -- No --> RC[Rechazo del correo
responder al remitente] I -- Sí --> J[6. procesar_pdfs]:::highlight J --> K[7. procesar_xmls]:::highlight K --> K2[8. completar_guias] K2 --> K3[9. validar_guias] K3 --> L{Hay documentos?} - L -- No --> C + L -- No --> RC L -- Sí --> M[10. guardar_resultado] M --> N[11. Filtrar facturas tipo 01] N --> O{Hay facturas?} - O -- No --> C + O -- No --> RC O -- Sí --> P[Bucle: por cada factura] P --> 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{Todas coinciden?} - T -- Sí --> U[15. sapb1_manager.create_draft] - T -- No --> V[⏭️ Draft NO creado] - U --> W[16. outlook_utilities.send_email] + S --> T{¿Error de SAP/HANA?} + T -- Sí --> X[estado error] + T -- No --> T2{Todas coinciden?} + T2 -- Sí --> U[15. sapb1_manager.create_draft] + T2 -- No --> V[estado rechazado + observaciones] + U --> U2[estado aceptado] + 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] W --> P + W2 --> P classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px; ``` @@ -174,5 +182,23 @@ run_pipeline │ ├── guardar_draft_json │ ├── sapb1_manager.create_draft (si todas las líneas coinciden) │ ├── build_notification_body - │ └── outlook_utilities.send_email + │ ├── respuesta_proveedor.enviar_a_equipo (correo técnico, solo equipo) + │ └── respuesta_proveedor.responder (plantilla aceptado/rechazado, ver respuesta_proveedor.md) ``` + +--- + +## Qué se envía y a quién (paso 16) + +| 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 | +| 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. + diff --git a/docs/respuesta_proveedor.md b/docs/respuesta_proveedor.md new file mode 100644 index 0000000..714a3d2 --- /dev/null +++ b/docs/respuesta_proveedor.md @@ -0,0 +1,92 @@ +# respuesta_proveedor.py — Respuesta al proveedor + +Cuando alguien envía un correo con el XML y el PDF de una factura, el sistema valida y **responde a esa misma dirección** con la plantilla de **aceptado** o de **rechazado** (`plantilla/plantilla-aceptado.html` y `plantilla-rechazado.html`). Aparte, el equipo recibe un correo técnico (tablas de SAP, JSON adjuntos). + +Piezas: `respuesta_proveedor.py` (contexto, protecciones, envío), `observaciones.py` (motivos de rechazo y sus textos), `plantillas.py` (renderizador) y `seguimiento.py` (código de seguimiento y registro). + +## Qué se responde + +| Situación | Respuesta al remitente | +|-----------|------------------------| +| 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 | +| 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 | +| 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. + +## Modo prueba y modo real + +`NOTIFICACION_MODO` en el `.env`: + +| Modo | A quién va la respuesta | Asunto | +|------|-------------------------|--------| +| `prueba` (**por defecto**) | Al equipo (`NOTIFICACION_EQUIPO`), **nunca** al remitente | `[PRUEBA → remitente] Aceptado · Factura F002-486 · ABR-…` | +| `real` | Al remitente, con **copia oculta** al equipo | `Aceptado · Factura F002-486 · ABR-…` | + +Cualquier otro valor se trata como `prueba`. Al arrancar, `config_validator` deja en el log una advertencia con el modo activo. Pasar a `real` es una decisión explícita: no ocurre solo. + +## Protecciones: cuándo NO se responde + +El sistema contesta solo y a terceros, así que no responde a: + +| Caso | Motivo registrado | +|------|-------------------| +| Remitente vacío o desconocido | remitente desconocido | +| Dirección sin dominio válido (por ejemplo `test@local`) | dirección no válida | +| El propio buzón del sistema | el remitente es el propio buzón | +| `no-reply`, `noreply`, `do-not-reply`, `no-responder`, `mailer-daemon`, `postmaster`, `bounce` | dirección automática | +| Mensajes automáticos (respuestas automáticas, rebotes, listas) | mensaje automático | +| En correos **sin comprobante**: asunto con `Re:`/`RV:` o que cita un código `ABR-…` | parece una respuesta a una notificación nuestra | +| En correos sin comprobante: más de 3 respuestas al mismo remitente en 24 h | límite de respuestas | + +Cada omisión queda en `comprobantes` como `notificacion_estado = omitida`, con el motivo. + +## Qué ve el proveedor + +- **Importe por guía:** el importe de la línea aparece una vez, en su primera guía; las demás dicen "incluida en la línea". Todas las guías de una línea llevan el mismo resultado, porque la validación es por línea. +- **Rechazo por precio:** se muestran "Importe en su factura" e "Importe esperado" (el de SAP). +- **Pago estimado:** solo si hay condición de pago (fecha de vencimiento posterior a la de contabilización). +- **Sin adjuntos internos:** la respuesta no lleva archivos. + +## Observaciones (`observaciones.py`) + +Los textos son un **borrador para revisar**; se cambian en ese archivo sin tocar la lógica. + +| Código | Cuándo | +|--------|--------| +| `precio_no_coincide` | El total de la línea del XML no coincide con la suma de SAP | +| `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 | +| `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) | + +## Configuración (`.env`) + +| Variable | Para qué | Por defecto | +|----------|----------|-------------| +| `NOTIFICACION_MODO` | `prueba` o `real` | `prueba` | +| `NOTIFICACION_EQUIPO` | Direcciones internas (coma o `;`): correo técnico, y respuestas en modo prueba | `jlopez@aberio.com.pe`, `gcalle@pucp.pe` | +| `NOTIFICACION_CONTACTO` | Dirección del botón "Escribir a Aberio" | el buzón `EMAIL` | +| `LOGO_URL` | URL pública (https) del logo | vacío: sin logo | + +El buzón `EMAIL` es también el `envio_correo` al que se pide reenviar el comprobante corregido. + +## Cómo probar + +1. **Vista previa y envío de ejemplos:** `python tools/previsualizar_plantillas.py` (ver su ayuda). Los ejemplos están en `plantilla/ejemplos.json`. +2. **Piloto en modo prueba:** con `NOTIFICACION_MODO=prueba` el flujo completo corre y las respuestas llegan solo al equipo, indicando en el asunto a quién habrían ido. El correo técnico también. +3. **Pasar a real:** cuando el equipo valide los correos, cambiar a `NOTIFICACION_MODO=real`. Revisar antes que `LOGO_URL` tenga valor. + +## 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. +- 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 cb80af9..289cbf4 100644 --- a/docs/seguimiento.md +++ b/docs/seguimiento.md @@ -22,6 +22,7 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d | `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_destinatario` | Direcciones a las que se envió **de verdad** (en modo prueba no son las del remitente) | | `creado_en`, `actualizado_en` | Marcas de tiempo | ## Funciones @@ -32,7 +33,8 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d | `registrar_correo(email_id)` | Igual, para un correo sin comprobante. Idempotente por correo. | | `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)` | Guarda el estado de la notificación. Mismas excepciones. | +| `registrar_notificacion(codigo, estado, error=None, destinatario=None)` | Guarda el estado de la notificación y a quién se envió. Mismas excepciones. | +| `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 @@ -44,12 +46,17 @@ 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. + 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. -## Estado actual y pendiente +## Observaciones guardadas -- Los `observaciones` guardados hoy son mínimos. Se reemplazarán por observaciones estructuradas (título, filas, cómo corregir) cuando se definan las validaciones de rechazo. -- `registrar_correo` y `registrar_notificacion` están implementados y probados, pero el pipeline todavía no los llama: los usarán las notificaciones al proveedor. +- Rechazo de una factura: la lista de observaciones completa (`codigo`, `titulo`, `filas`, `como_corregir`; ver `observaciones.py`). +- Error: `[{"codigo": "error_interno", "detalle": "..."}]`. +- 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 diff --git a/draft_sap.py b/draft_sap.py index f0bcb46..70b2fae 100644 --- a/draft_sap.py +++ b/draft_sap.py @@ -11,6 +11,35 @@ from pipeline_utils import log # ============================================================================ +def hay_error_de_consulta(resultados_val: list[dict]) -> str | None: + """ + Devuelve el detalle del primer error de consulta a SAP que dejó ``validate_prices`` + (una guía cuya consulta lanzó excepción), o None si no hubo ninguno. Un error de + consulta es una falla nuestra: no debe confundirse con una guía inexistente. + """ + for resultado in resultados_val: + for item in resultado.get("guias_lt", []): + if item.get("error"): + return item["error"] + return None + + +def hana_disponible() -> bool: + """ + True si se puede abrir una conexión a SAP HANA. ``get_delivery_notes`` devuelve ``[]`` + tanto si una guía no existe como si HANA está caído; antes de decirle a un proveedor + que una guía "no se encontró" conviene descartar que haya sido una caída. + """ + conexion = hana_manager.get_connection() + if conexion is None: + return False + try: + conexion.close() + except Exception: + pass + return True + + def calcular_fecha_vencimiento(fecha_base: str, condicion_pago: dict | None) -> str: """ Calcula la fecha de vencimiento sumando a ``fecha_base`` los días de @@ -341,7 +370,7 @@ def validate_prices(factura: dict) -> tuple[bool, list[dict]]: all_wh.update(wh_set) 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}) + 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)}) all_ok = False sap_total = sum(item["line_total"] for item in guias_lt) diff --git a/observaciones.py b/observaciones.py new file mode 100644 index 0000000..9505a2f --- /dev/null +++ b/observaciones.py @@ -0,0 +1,157 @@ +""" +Catálogo de observaciones: los motivos por los que se rechaza un comprobante. + +Cada observación se muestra al proveedor en la plantilla de rechazo con un +título, unas filas de detalle y un texto de "cómo corregir". **Los textos de +este archivo son un borrador** para revisar y ajustar; no hace falta tocar la +lógica para cambiarlos. + +``desde_validacion`` convierte el resultado de ``draft_sap.validate_prices`` en +observaciones; el resto son los motivos de un correo sin comprobante válido. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Observacion: + codigo: str # identificador estable (se guarda en la BD) + titulo: str + filas: tuple[tuple[str, str], ...] = () # (etiqueta, valor) de detalle + como_corregir: str = "" + + def a_dict(self) -> dict: + """Forma serializable: se guarda en ``comprobantes.observaciones``.""" + return { + "codigo": self.codigo, + "titulo": self.titulo, + "filas": [{"etiqueta": e, "valor": v} for e, v in self.filas], + "como_corregir": self.como_corregir, + } + + +# ============================================================================ +# Formato +# ============================================================================ +def moneda(valor: float | int | None) -> str: + """``1250`` → ``'S/ 1,250.00'``.""" + return f"S/ {float(valor or 0):,.2f}" + + +def texto_observaciones(cantidad: int) -> str: + """``1`` → ``'1 observación'``, ``2`` → ``'2 observaciones'``.""" + return f"{cantidad} observación" if cantidad == 1 else f"{cantidad} observaciones" + + +def _nombre_guia(guia: dict) -> str: + return f"{guia.get('serie', '')}-{guia.get('numero', '')}" + + +# ============================================================================ +# Motivos de rechazo de una factura (validación contra SAP) +# ============================================================================ +def precio_no_coincide(numero_linea: int, importe_factura: float, importe_esperado: float, + guias: list[str], varias_lineas: bool) -> Observacion: + filas = [] + if varias_lineas: + filas.append(("Línea", str(numero_linea))) + filas += [ + ("Guías", ", ".join(guias)), + ("Importe en su factura", moneda(importe_factura)), + ("Importe esperado", moneda(importe_esperado)), + ] + return Observacion( + "precio_no_coincide", "El importe no coincide con nuestro registro", tuple(filas), + "Revise el importe acordado para este servicio y emita el comprobante con el valor correcto.", + ) + + +def guia_no_encontrada(guia: str) -> Observacion: + return Observacion( + "guia_no_encontrada", f"No encontramos la guía {guia}", (("Guía", guia),), + "Verifique que la guía esté registrada y que su serie y número estén bien escritos en la factura.", + ) + + +def linea_sin_guia(numero_linea: int, descripcion: str) -> Observacion: + filas = [("Línea", str(numero_linea))] + if descripcion: + filas.append(("Descripción", descripcion[:60])) + return Observacion( + "linea_sin_guia", "Una línea de la factura no indica su guía de remisión", tuple(filas), + "Indique la guía de remisión de cada servicio en la factura y envíela nuevamente.", + ) + + +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)),), + "Corrija la cantidad de la línea y emita el comprobante nuevamente.", + ) + + +# ============================================================================ +# Motivos de rechazo de un correo sin comprobante válido +# ============================================================================ +def sin_pdf_xml(archivos_recibidos: int) -> Observacion: + return Observacion( + "sin_pdf_xml", "No recibimos el XML ni el PDF de su comprobante", + (("Archivos recibidos", str(archivos_recibidos)),), + "Adjunte el XML y el PDF de la factura electrónica en el mismo correo.", + ) + + +def falta_xml(pdfs_recibidos: int) -> Observacion: + return Observacion( + "falta_xml", "Falta el archivo XML de su comprobante", (("PDF recibidos", str(pdfs_recibidos)),), + "Adjunte también el XML de la factura electrónica: el PDF por sí solo no es suficiente.", + ) + + +def sin_factura(documentos_recibidos: list[str]) -> Observacion: + return Observacion( + "sin_factura", "No encontramos una factura electrónica en su correo", + (("Documentos recibidos", ", ".join(documentos_recibidos) or "ninguno reconocible"),), + "Envíe el XML y el PDF de la factura electrónica. Los demás documentos no se registran por este medio.", + ) + + +# ============================================================================ +# Resultado de la validación de precios → observaciones +# ============================================================================ +def _sin_datos_sap(item: dict) -> bool: + """La guía no devolvió ningún dato de SAP (y no fue por un error de consulta).""" + return item.get("line_total") == 0 and item.get("quantity") == 0 and not item.get("error") + + +def desde_validacion(factura: dict, resultados_val: list[dict]) -> list[Observacion]: + """ + Observaciones de una factura a partir de los resultados de ``validate_prices`` + (uno por línea). Una línea que no coincide da una observación por cada guía + que no se encontró y, si hay datos de SAP para alguna otra, una de importe. + """ + lineas = factura.get("lineas", []) + varias_lineas = len(lineas) > 1 + observaciones: list[Observacion] = [] + + for i, resultado in enumerate(resultados_val): + if resultado.get("ok"): + continue + numero = i + 1 + linea = lineas[i] if i < len(lineas) else {} + etiqueta = resultado.get("label", "") + + if etiqueta == "Cantidad cero, sin validar": + observaciones.append(cantidad_cero(numero)) + elif etiqueta == "Sin guía de referencia": + observaciones.append(linea_sin_guia(numero, linea.get("descripcion", ""))) + else: + guias_lt = resultado.get("guias_lt", []) + no_encontradas = [it for it in guias_lt if _sin_datos_sap(it)] + observaciones += [guia_no_encontrada(_nombre_guia(it["guia"])) for it in no_encontradas] + if len(no_encontradas) < len(guias_lt): + observaciones.append(precio_no_coincide( + numero, float(linea.get("total_linea") or 0), float(resultado.get("sap_price") or 0), + [_nombre_guia(it["guia"]) for it in guias_lt], varias_lineas, + )) + return observaciones diff --git a/outlook_utilities.py b/outlook_utilities.py index 8668588..ec5ed43 100644 --- a/outlook_utilities.py +++ b/outlook_utilities.py @@ -52,11 +52,30 @@ def get_access_token() -> str: correlation_id = result.get("correlation_id") raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})") +# ============================================================================ +def es_mensaje_automatico(headers: list[dict] | None) -> bool: + """ + Indica si un mensaje fue generado automáticamente (respuesta automática, + rebote, lista de distribución…) a partir de ``internetMessageHeaders``. + + No se debe responder a estos mensajes: se generarían bucles de correos. + """ + h = {(x.get("name") or "").lower(): (x.get("value") or "").strip().lower() for x in headers or []} + if h.get("auto-submitted", "no") not in ("", "no"): + return True + if "x-auto-response-suppress" in h or "x-autoreply" in h or "x-autorespond" in h: + return True + if h.get("precedence") in ("bulk", "junk", "list", "auto_reply"): + return True + return "report-type=" in h.get("content-type", "") # multipart/report: acuse o rebote de entrega + + # ============================================================================ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -> list[dict]: """ Obtiene los correos de una cuenta específica en un rango de fechas. - Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach. + Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject, has_attach + y es_automatico (True si es una respuesta automática o un rebote). """ headers = { "Authorization": f"Bearer {token}", @@ -73,7 +92,7 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) - params = { "$filter": filter_query, - "$select": "id,receivedDateTime,hasAttachments,subject,from", + "$select": "id,receivedDateTime,hasAttachments,subject,from,internetMessageHeaders", "$top": 50 } @@ -107,7 +126,8 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) - "fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"), "sender": sender, "subject": msg.get("subject", ""), - "has_attach": msg.get("hasAttachments") + "has_attach": msg.get("hasAttachments"), + "es_automatico": es_mensaje_automatico(msg.get("internetMessageHeaders")), }) url = data.get("@odata.nextLink") @@ -153,7 +173,7 @@ def download_attachments(user_email: str, message_id: str, token: str, download_ return downloaded_files # ============================================================================ -def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None) -> bool: +def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None, bcc_recipients: list[str] | None = None) -> bool: """ Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF. @@ -164,6 +184,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub :param html_body: Contenido del correo en formato HTML. :param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán. :param cc_recipients: Lista de correos electrónicos en copia (CC). + :param bcc_recipients: Lista de correos electrónicos en copia oculta (BCC): no los ve el resto de destinatarios. :return: True si se envió correctamente, False en caso contrario. """ url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail" @@ -192,6 +213,9 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub } }) + # Construir lista de destinatarios en copia oculta (BCC) para Graph API + bcc_payload = [{"emailAddress": {"address": email}} for email in (bcc_recipients or [])] + # Construir lista de adjuntos attachments_payload = [] if attachment_paths: @@ -229,6 +253,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub }, "toRecipients": recipients_payload, "ccRecipients": cc_payload, + "bccRecipients": bcc_payload, "attachments": attachments_payload }, "saveToSentItems": "true" diff --git a/pipeline.py b/pipeline.py index e6865bd..648ef05 100644 --- a/pipeline.py +++ b/pipeline.py @@ -20,6 +20,8 @@ import documentos as docs import persistencia import draft_sap import notificaciones +import observaciones +import respuesta_proveedor import sapb1_manager import seguimiento from pipeline_utils import log @@ -61,6 +63,33 @@ def _registrar_resultado(registro: dict | None, estado: str, **kwargs) -> None: log(f"⚠️ No se pudo guardar el resultado en seguimiento: {ex}", severity=logging.WARNING) +def _registrar_correo(email: dict) -> dict | None: + """Como ``_registrar_comprobante``, para un correo sin comprobante identificable.""" + try: + registro = seguimiento.registrar_correo(email["id"]) + except Exception as ex: + log(f"⚠️ No se pudo registrar el correo en seguimiento: {ex}", severity=logging.WARNING) + return None + log(f"🔖 Código de seguimiento: {registro['codigo_seguimiento']}") + return registro + + +def _rechazar_correo(email: dict, token: str, email_addr: str, lista_obs: list) -> None: + """ + Rechaza un correo que no trae ningún comprobante válido: registra el motivo y + responde al remitente con la plantilla de rechazo (si las protecciones lo permiten). + """ + log(f"❌ Correo rechazado: {lista_obs[0].titulo}", severity=logging.WARNING) + registro = _registrar_correo(email) + _registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs]) + if not registro or not token: + return + respuesta_proveedor.responder( + token, email_addr, email, "rechazado", + respuesta_proveedor.contexto_rechazo_correo(email, registro, lista_obs), registro, sin_comprobante=True, + ) + + # ============================================================================ # PASO 1: Autenticación Microsoft Graph # ============================================================================ @@ -146,14 +175,16 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde # 3. Descargar adjuntos lista_adjuntos = adjuntos.descargar_adjuntos(email, email_addr, token, download_folder) if not lista_adjuntos: - log("⏭️ Correo sin adjuntos") + log("⏭️ Correo sin adjuntos") # no se responde: no hubo intento de enviar un comprobante return + n_recibidos = len(lista_adjuntos) # 4. Descomprimir ZIPs email_utilities.descomprimir_adjuntos([email], download_folder) # 5. Filtrar PDF/XML y limpiar si no hay válidos if not adjuntos.filtrar_y_limpiar(email, download_folder): + _rechazar_correo(email, token, email_addr, [observaciones.sin_pdf_xml(n_recibidos)]) return # 6. Procesar PDFs @@ -163,6 +194,11 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde documentos = docs.procesar_xmls(email, download_folder, pdf_cache) if not documentos: log("⏭️ No se extrajeron documentos XML") + if any(a.lower().endswith(".xml") for a in email["adjuntos"]): + motivo = observaciones.sin_factura([]) + else: + motivo = observaciones.falta_xml(sum(a.lower().endswith(".pdf") for a in email["adjuntos"])) + _rechazar_correo(email, token, email_addr, [motivo]) return # 8. Completar guías faltantes desde PDF @@ -181,6 +217,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde facturas = [d for d in documentos if d.get("tipo") == "01"] if not facturas: log("⏭️ No hay facturas (tipo 01) para SAP") + _rechazar_correo(email, token, email_addr, + [observaciones.sin_factura(sorted({d.get("tipo_descripcion", "?") for d in documentos}))]) return for factura in facturas: @@ -190,55 +228,89 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde log(f"📄 Factura: {serie}-{numero}") registro = _registrar_comprobante(email, factura) - # 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) - 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) - # 14. Guardar draft JSON (siempre, para depuración) - persistencia.guardar_draft_json(draft, serie, numero, output_folder) + estado = None # aceptado | rechazado | error + detalle_error = None + lista_obs: list = [] + resultados_val: list = [] + draft: dict = {} + validation_table = valor_referencial_table = "" - # 15. Crear draft en SAP o generar HTML - if todas_coinciden: + 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" + 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) - _registrar_resultado(registro, "error", observaciones=[{"codigo": "error_sap", "detalle": str(ex)}]) - html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex) else: + estado = "rechazado" log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló") - _registrar_resultado(registro, "rechazado", observaciones=[{"codigo": "precios_no_coinciden"}]) + _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) - # 16. Enviar notificación al remitente original, con copia al equipo - if token: - destinatario = email.get("sender") - if destinatario and destinatario != "Desconocido": - outlook_utilities.send_email( - token, email_addr, - [destinatario], - f"Draft SAP B1 - {serie}-{numero}", - html_body, adjuntos_email, - cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"], - ) - else: - log("⚠️ No se pudo determinar el remitente, notificación no enviada", severity=logging.WARNING) + 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") # ============================================================================ diff --git a/respuesta_proveedor.py b/respuesta_proveedor.py new file mode 100644 index 0000000..5f5efc6 --- /dev/null +++ b/respuesta_proveedor.py @@ -0,0 +1,285 @@ +""" +Respuesta al proveedor: arma y envía el correo de aceptación o rechazo (plantillas +``plantilla/plantilla-aceptado.html`` y ``plantilla-rechazado.html``) a quien envió +el correo con el XML y el PDF. + +Dos modos, según ``NOTIFICACION_MODO`` en el .env: + + prueba (por defecto) la respuesta NO sale al proveedor: va al equipo + (``NOTIFICACION_EQUIPO``) con el asunto "[PRUEBA → remitente]". + real la respuesta va al remitente, con copia oculta al equipo. + +Como el sistema contesta solo y a terceros, **no responde** a: remitentes +desconocidos o sin dominio válido, el propio buzón, direcciones ``no-reply`` y +afines, mensajes automáticos (respuestas automáticas, rebotes) y, en correos sin +comprobante, a respuestas (``Re:``) o mensajes que citan un código nuestro. Además +limita a ``MAX_RESPUESTAS_CORREO`` por remitente cada 24 h las respuestas a correos +sin comprobante. +""" + +import logging +import os +import re +from datetime import datetime + +from config_validator import MODOS_NOTIFICACION as MODOS +import observaciones as obs +import outlook_utilities +import plantillas +import seguimiento +from pipeline_utils import log + +EQUIPO_POR_DEFECTO = ["jlopez@aberio.com.pe", "gcalle@pucp.pe"] +MAX_RESPUESTAS_CORREO = 3 + +_DIRECCION = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +_AUTOMATICA = re.compile(r"no[-_.]?reply|do[-_.]?not[-_.]?reply|no[-_.]?responder|mailer-daemon|postmaster|bounce", re.I) +_CODIGO = re.compile(r"ABR-\d{6}-\d{5}") +_RESPUESTA = re.compile(r"^\s*(re|rv)\s*:", re.I) + + +# ============================================================================ +# Configuración (.env) +# ============================================================================ +def modo() -> str: + """``prueba`` o ``real``. Cualquier otro valor (o ninguno) es ``prueba``.""" + valor = os.getenv("NOTIFICACION_MODO", "prueba").strip().lower() + return valor if valor in MODOS else "prueba" + + +def equipo() -> list[str]: + """Direcciones internas (``NOTIFICACION_EQUIPO``, separadas por coma o punto y coma).""" + lista = [d.strip() for d in re.split(r"[;,]", os.getenv("NOTIFICACION_EQUIPO", "")) if d.strip()] + return lista or list(EQUIPO_POR_DEFECTO) + + +def contacto_correo() -> str: + return os.getenv("NOTIFICACION_CONTACTO", "").strip() or os.getenv("EMAIL", "").strip() + + +def envio_correo() -> str: + """Buzón al que el proveedor debe reenviar el comprobante corregido.""" + return os.getenv("EMAIL", "").strip() + + +def logo_url() -> str: + return os.getenv("LOGO_URL", "").strip() + + +# ============================================================================ +# Protecciones +# ============================================================================ +def motivo_omision(email: dict, buzon: str, sin_comprobante: bool = False) -> str | None: + """ + Motivo por el que NO se debe responder a este correo, o None si se puede. + + Args: + email: Dict del correo (``sender``, ``subject``, ``es_automatico``). + buzon: Dirección del buzón del sistema (``EMAIL``). + sin_comprobante: True si la respuesta es un rechazo de un correo sin comprobante válido. + """ + remitente = (email.get("sender") or "").strip() + if not remitente or remitente == "Desconocido": + return "remitente desconocido" + if not _DIRECCION.match(remitente): + return f"dirección no válida ({remitente})" + if remitente.lower() == buzon.strip().lower(): + return "el remitente es el propio buzón" + if _AUTOMATICA.search(remitente.split("@")[0]): + return "dirección automática (no-reply)" + if email.get("es_automatico"): + return "mensaje automático (respuesta automática o rebote)" + if sin_comprobante: + asunto = email.get("subject") or "" + if _RESPUESTA.match(asunto) or _CODIGO.search(asunto): + return "parece una respuesta a una notificación nuestra" + return None + + +def _destinatarios(remitente: str) -> tuple[list[str], list[str], str]: + """(para, copia oculta, prefijo del asunto) según el modo.""" + if modo() == "real": + return [remitente], equipo(), "" + return equipo(), [], f"[PRUEBA → {remitente}] " + + +# ============================================================================ +# Contexto de las plantillas +# ============================================================================ +def _fecha_hora(email: dict) -> tuple[str, str]: + try: + dt = datetime.strptime(email["fecha"], "%Y-%m-%d %H:%M:%S") + except (KeyError, ValueError, TypeError): + dt = datetime.now() + return f"{dt:%d/%m/%Y}", f"{dt:%H:%M}" + + +def _comun(email: dict, registro: dict) -> dict: + fecha, hora = _fecha_hora(email) + return { + "logo_url": logo_url(), + "recepcion_fecha": fecha, + "recepcion_hora": hora, + "codigo_seguimiento": registro["codigo_seguimiento"], + "contacto_correo": contacto_correo(), + } + + +def _comprobante(factura: dict) -> dict: + numero = f"{factura.get('serie', '')}-{factura.get('numero', '')}" + tipo = "Factura electrónica" if factura.get("tipo") == "01" else factura.get("tipo_descripcion", "Comprobante") + return { + "comprobante_tipo_corto": "factura", + "comprobante_tipo": tipo, + "comprobante_numero": numero, + "proveedor_razon_social": factura.get("razon_social_emisor") or "—", + "proveedor_ruc": factura.get("ruc_emisor") or None, + } + + +def guias_para_plantilla(factura: dict, resultados_val: list[dict]) -> list[dict]: + """ + Filas de la tabla "Guías revisadas". La validación es por línea, así que todas las + guías de una línea llevan el mismo resultado; el importe de la línea se muestra una + vez (en su primera guía) y las demás dicen que están incluidas en la línea. + """ + filas = [] + for i, linea in enumerate(factura.get("lineas", [])): + resultado = resultados_val[i] if i < len(resultados_val) else {} + guias = [it["guia"] for it in resultado.get("guias_lt", [])] or linea.get("guias_remision") or [] + for j, guia in enumerate(guias): + filas.append({ + "guia": f"{guia.get('serie', '')}-{guia.get('numero', '')}", + "importe_factura": obs.moneda(linea.get("total_linea")) if j == 0 else "incluida en la línea", + "conforme": bool(resultado.get("ok")), + }) + return filas + + +def _fecha_estimada_pago(draft: dict) -> str | None: + """Fecha de vencimiento del draft, solo si es posterior a la de contabilización (hay condición de pago).""" + try: + vencimiento = datetime.strptime(draft["DocDueDate"], "%Y-%m-%d") + contabilizacion = datetime.strptime(draft["DocDate"], "%Y-%m-%d") + except (KeyError, ValueError, TypeError): + return None + return f"{vencimiento:%d/%m/%Y}" if vencimiento > contabilizacion else None + + +def contexto_aceptado(email: dict, registro: dict, factura: dict, resultados_val: list[dict], draft: dict) -> dict: + comprobante = _comprobante(factura) + numero = comprobante["comprobante_numero"] + return { + **_comun(email, registro), **comprobante, + "asunto": f"Aceptado · Factura {numero} · {registro['codigo_seguimiento']}", + "preheader": f"Su factura {numero} pasó todas las validaciones. No necesita hacer nada más.", + "fecha_estimada_pago": _fecha_estimada_pago(draft), + "guias": guias_para_plantilla(factura, resultados_val), + } + + +def _bloque_observaciones(observaciones: list[obs.Observacion]) -> dict: + texto = obs.texto_observaciones(len(observaciones)) + return { + "observaciones_texto": texto, + "preheader": f"Tiene {texto}. Corríjalas y envíenos el comprobante nuevamente.", + "observaciones": [ + {"numero": n, "titulo": o.titulo, "como_corregir": o.como_corregir, + "filas": [{"etiqueta": e, "valor": v} for e, v in o.filas]} + for n, o in enumerate(observaciones, start=1) + ], + "envio_correo": envio_correo(), + } + + +def contexto_rechazo_factura(email: dict, registro: dict, factura: dict, resultados_val: list[dict], + observaciones: list[obs.Observacion]) -> dict: + comprobante = _comprobante(factura) + guias = guias_para_plantilla(factura, resultados_val) + return { + **_comun(email, registro), **comprobante, **_bloque_observaciones(observaciones), + "asunto": f"Rechazado · Factura {comprobante['comprobante_numero']} · {registro['codigo_seguimiento']}", + "guias": guias, "tiene_guias": bool(guias), + } + + +def contexto_rechazo_correo(email: dict, registro: dict, observaciones: list[obs.Observacion]) -> dict: + """Rechazo de un correo sin comprobante identificable: no hay proveedor, número ni guías que mostrar.""" + return { + **_comun(email, registro), **_bloque_observaciones(observaciones), + "asunto": f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}", + "comprobante_tipo_corto": "comprobante", "comprobante_tipo": "", "comprobante_numero": None, + "proveedor_razon_social": email.get("sender") or "—", "proveedor_ruc": None, + "guias": [], "tiene_guias": False, + } + + +# ============================================================================ +# Envío +# ============================================================================ +def _anotar(codigo: str, estado: str, error: str | None = None, destinatario: str | None = None) -> None: + try: + seguimiento.registrar_notificacion(codigo, estado, error=error, destinatario=destinatario) + except Exception as ex: + log(f"⚠️ No se pudo registrar la notificación en seguimiento: {ex}", severity=logging.WARNING) + + +def responder(token: str, buzon: str, email: dict, plantilla: str, contexto: dict, registro: dict, + sin_comprobante: bool = False) -> str: + """ + Responde al remitente con la plantilla ``plantilla`` ("aceptado" o "rechazado"). + + Returns: + ``"enviada"``, ``"fallida"`` u ``"omitida"``; también queda anotado en ``comprobantes``. + """ + codigo = registro["codigo_seguimiento"] + remitente = email.get("sender") or "" + + motivo = motivo_omision(email, buzon, sin_comprobante) + if motivo is None and sin_comprobante and seguimiento.contar_respuestas_correo(remitente) >= MAX_RESPUESTAS_CORREO: + motivo = f"ya se respondió {MAX_RESPUESTAS_CORREO} veces a este remitente en 24 h" + if motivo: + log(f"⏭️ No se responde al proveedor: {motivo}", severity=logging.WARNING) + _anotar(codigo, "omitida", motivo) + return "omitida" + + para, copia_oculta, prefijo = _destinatarios(remitente) + try: + html = plantillas.renderizar_plantilla(plantilla, contexto) + except plantillas.PlantillaError as ex: + log(f"❌ No se pudo armar la respuesta ({codigo}): {ex}", severity=logging.ERROR) + _anotar(codigo, "fallida", f"Plantilla: {ex}") + return "fallida" + + 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" + 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)) + return estado + + +def omitir_respuesta(registro: dict, motivo: str) -> None: + """Deja anotado que a este comprobante no se le respondió, y por qué.""" + _anotar(registro["codigo_seguimiento"], "omitida", motivo) + + +def encabezado_tecnico(email: dict, registro: dict | None) -> str: + """Datos de contexto que se anteponen al correo técnico que recibe el equipo.""" + codigo = registro["codigo_seguimiento"] if registro else "(sin código: falló el seguimiento)" + return ( + f"

Remitente: {email.get('sender', '')}
" + f"Asunto: {email.get('subject', '')}
" + f"Código de seguimiento: {codigo}
" + f"Modo de notificación: {modo()}

" + ) + + +def enviar_a_equipo(token: str, buzon: str, asunto: str, html: str, adjuntos: list[str] | None = None) -> bool: + """Envía el correo técnico (tablas de SAP, JSON adjuntos…) solo al equipo interno.""" + destinos = equipo() + if not destinos: + return False + return outlook_utilities.send_email(token, buzon, destinos, asunto, html, adjuntos) diff --git a/seguimiento.py b/seguimiento.py index 1fdf87c..6541e9c 100644 --- a/seguimiento.py +++ b/seguimiento.py @@ -18,7 +18,7 @@ El esquema se crea en ``database_utilities.init_db``. import json import sqlite3 from contextlib import contextmanager -from datetime import datetime +from datetime import datetime, timedelta import database_utilities @@ -159,7 +159,8 @@ def actualizar_resultado(codigo_seguimiento: str, estado: str, observaciones: li raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}") -def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None) -> None: +def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None, + destinatario: str | None = None) -> None: """ Guarda el estado de la notificación enviada al proveedor. @@ -167,6 +168,7 @@ def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | No codigo_seguimiento: Código del registro. estado: Uno de ``ESTADOS_NOTIFICACION``. error: Motivo, si la notificación falló o se omitió. + destinatario: Direcciones a las que se envió realmente (en modo prueba no son las del remitente). Raises: ValueError: Si ``estado`` no es válido. @@ -179,9 +181,26 @@ def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | No with _conexion() as conn: cursor = conn.execute( """UPDATE comprobantes - SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?, actualizado_en = ? + SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?, + notificacion_destinatario = ?, actualizado_en = ? WHERE codigo_seguimiento = ?""", - (estado, error, ahora, ahora, codigo_seguimiento), + (estado, error, ahora, destinatario, ahora, codigo_seguimiento), ) if cursor.rowcount == 0: raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}") + + +def contar_respuestas_correo(remitente: str, horas: int = 24) -> int: + """ + Cuenta las respuestas ya enviadas en las últimas ``horas`` a un remitente por + correos **sin comprobante** (sin serie ni número). Sirve de freno contra bucles + y contra remitentes que insisten con correos inválidos. + """ + desde = (datetime.now() - timedelta(hours=horas)).strftime("%Y-%m-%d %H:%M:%S") + with _conexion() as conn: + return conn.execute( + """SELECT COUNT(*) FROM comprobantes c JOIN emails e ON e.id = c.email_id + WHERE c.serie IS NULL AND c.notificacion_estado = 'enviada' + AND c.notificacion_fecha >= ? AND lower(e.sender) = lower(?)""", + (desde, remitente), + ).fetchone()[0] diff --git a/tests/conftest.py b/tests/conftest.py index e274b9d..a92c062 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,3 +23,14 @@ def base_de_datos_temporal(tmp_path_factory, monkeypatch): """ monkeypatch.setattr(database_utilities, "DB_NAME", str(tmp_path_factory.mktemp("bd") / "emails_prueba.db")) database_utilities.init_db() + + +@pytest.fixture(autouse=True) +def entorno_de_notificacion_aislado(monkeypatch): + """ + Los módulos cargan el ``.env`` real al importarse. Las pruebas no deben depender de él: + sin esto, un ``NOTIFICACION_MODO=real`` del .env haría que se probara (y enviara) en modo real. + """ + for variable in ("NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "NOTIFICACION_CONTACTO", "LOGO_URL"): + monkeypatch.delenv(variable, raising=False) + monkeypatch.setenv("EMAIL", "buzon@aberio.com") diff --git a/tests/test_config_validator.py b/tests/test_config_validator.py index 2c5229c..8a15aea 100644 --- a/tests/test_config_validator.py +++ b/tests/test_config_validator.py @@ -5,7 +5,8 @@ import pytest import config_validator as cv OBLIGATORIAS = [v for grupo in cv.OBLIGATORIAS.values() for v in grupo] -OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", *cv.VERIFICACION_SSL] +OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", "NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "LOGO_URL", + *cv.VERIFICACION_SSL] @pytest.fixture @@ -74,3 +75,32 @@ def test_verificar_termina_con_codigo_1_si_hay_errores(env_completo, monkeypatch def test_verificar_no_termina_si_todo_esta_bien(env_completo): cv.verificar_configuracion() # no debe lanzar SystemExit + + +def test_el_modo_de_notificacion_se_anuncia_al_arrancar(env_completo, monkeypatch): + _, advertencias = cv.validar_configuracion() + assert any("PRUEBA" in a for a in advertencias) and not any("REAL" in a for a in advertencias) + + monkeypatch.setenv("NOTIFICACION_MODO", "Real") + _, advertencias = cv.validar_configuracion() + assert any("REAL" in a and "quien envía el correo" in a for a in advertencias) + assert not any("PRUEBA" in a for a in advertencias) + + +def test_modo_de_notificacion_invalido_cae_en_prueba(env_completo, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "produccion") + errores, advertencias = cv.validar_configuracion() + assert errores == [] + assert any("produccion" in a and "se usará prueba" in a for a in advertencias) + + +def test_equipo_y_logo_sin_definir_solo_advierten(env_completo, monkeypatch): + errores, advertencias = cv.validar_configuracion() + assert errores == [] + assert any("NOTIFICACION_EQUIPO" in a for a in advertencias) and any("LOGO_URL" in a for a in advertencias) + + monkeypatch.setenv("NOTIFICACION_EQUIPO", "a@x.com") + monkeypatch.setenv("LOGO_URL", "https://x.com/logo.png") + _, advertencias = cv.validar_configuracion() + assert not any("NOTIFICACION_EQUIPO" in a or "LOGO_URL" in a for a in advertencias) + diff --git a/tests/test_observaciones.py b/tests/test_observaciones.py new file mode 100644 index 0000000..9fb1dbf --- /dev/null +++ b/tests/test_observaciones.py @@ -0,0 +1,98 @@ +import observaciones as obs + + +def _item(numero, line_total=100.0, quantity=10.0, error=None): + item = {"guia": {"tipo": "09", "serie": "T001", "numero": numero}, "line_total": line_total, "quantity": quantity} + if error: + item["error"] = error + return item + + +def _resultado(ok, guias, sap_price=100.0, label="LineTotal (FL*) vs total_linea"): + return {"ok": ok, "guias_lt": guias, "sap_price": sap_price, "label": label} + + +FACTURA = {"lineas": [{"total_linea": 624.38, "descripcion": "Servicio A"}]} + + +def test_formato(): + assert obs.moneda(1250) == "S/ 1,250.00" + assert obs.moneda(None) == "S/ 0.00" + assert obs.texto_observaciones(1) == "1 observación" + assert obs.texto_observaciones(3) == "3 observaciones" + + +def test_lineas_conformes_no_generan_observaciones(): + assert obs.desde_validacion(FACTURA, [_resultado(True, [_item("1")])]) == [] + + +def test_importe_distinto_muestra_ambos_importes(): + (o,) = obs.desde_validacion(FACTURA, [_resultado(False, [_item("1"), _item("2")], sap_price=674.38)]) + + assert o.codigo == "precio_no_coincide" + assert dict(o.filas) == {"Guías": "T001-1, T001-2", "Importe en su factura": "S/ 624.38", "Importe esperado": "S/ 674.38"} + assert "Línea" not in dict(o.filas) # con una sola línea no hace falta numerarla + + +def test_con_varias_lineas_se_indica_cual(): + factura = {"lineas": [{"total_linea": 10}, {"total_linea": 20}]} + resultados = [_resultado(True, [_item("1")]), _resultado(False, [_item("2")], sap_price=25.0)] + + (o,) = obs.desde_validacion(factura, resultados) + + assert dict(o.filas)["Línea"] == "2" + + +def test_guia_sin_datos_en_sap_es_guia_no_encontrada_y_no_importe(): + guias = [_item("1", 0.0, 0.0), _item("2", 0.0, 0.0)] + observaciones = obs.desde_validacion(FACTURA, [_resultado(False, guias, 0.0, "Sin guías SAP")]) + + assert [o.codigo for o in observaciones] == ["guia_no_encontrada", "guia_no_encontrada"] + assert [o.titulo for o in observaciones] == ["No encontramos la guía T001-1", "No encontramos la guía T001-2"] + + +def test_una_guia_encontrada_y_otra_no_da_ambas_observaciones(): + guias = [_item("1", 0.0, 0.0), _item("2", 40.0, 5.0)] + observaciones = obs.desde_validacion(FACTURA, [_resultado(False, guias, sap_price=40.0)]) + + assert [o.codigo for o in observaciones] == ["guia_no_encontrada", "precio_no_coincide"] + + +def test_una_guia_con_error_de_consulta_no_se_reporta_como_no_encontrada(): + guias = [_item("1", 0.0, 0.0, error="timeout")] + assert obs.desde_validacion(FACTURA, [_resultado(False, guias, 0.0)]) == [ + obs.precio_no_coincide(1, 624.38, 0.0, ["T001-1"], False) + ] + + +def test_lineas_sin_guia_o_con_cantidad_cero(): + factura = {"lineas": [{"descripcion": "Flete a Lima", "total_linea": 5}, {"total_linea": 7}]} + resultados = [_resultado(False, [], label="Sin guía de referencia"), _resultado(False, [], label="Cantidad cero, sin validar")] + + sin_guia, cero = obs.desde_validacion(factura, resultados) + + assert sin_guia.codigo == "linea_sin_guia" and dict(sin_guia.filas) == {"Línea": "1", "Descripción": "Flete a Lima"} + assert cero.codigo == "cantidad_cero" and dict(cero.filas) == {"Línea": "2"} + + +def test_motivos_de_correo_sin_comprobante(): + assert obs.sin_pdf_xml(2).filas == (("Archivos recibidos", "2"),) + assert obs.falta_xml(1).codigo == "falta_xml" + assert obs.sin_factura(["Guía de Remisión"]).filas == (("Documentos recibidos", "Guía de Remisión"),) + assert obs.sin_factura([]).filas == (("Documentos recibidos", "ninguno reconocible"),) + + +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([]), + ] + for o in muestras: + assert o.titulo and o.como_corregir and o.codigo + assert len({o.codigo for o in muestras}) == len(muestras) + + +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"]} diff --git a/tests/test_outlook.py b/tests/test_outlook.py new file mode 100644 index 0000000..0645986 --- /dev/null +++ b/tests/test_outlook.py @@ -0,0 +1,97 @@ +import outlook_utilities as o + + +# ============================================================================ +# es_mensaje_automatico +# ============================================================================ +def _h(**kv): + return [{"name": k.replace("_", "-"), "value": v} for k, v in kv.items()] + + +def test_mensajes_normales_no_son_automaticos(): + assert o.es_mensaje_automatico(None) is False + assert o.es_mensaje_automatico([]) is False + assert o.es_mensaje_automatico(_h(Auto_Submitted="no", Content_Type="text/html", Precedence="normal")) is False + + +def test_mensajes_automaticos(): + assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-replied")) is True + assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-generated")) is True + assert o.es_mensaje_automatico(_h(X_Auto_Response_Suppress="All")) is True + assert o.es_mensaje_automatico(_h(X_Autoreply="yes")) is True + assert o.es_mensaje_automatico(_h(Precedence="bulk")) is True + assert o.es_mensaje_automatico(_h(Precedence="auto_reply")) is True + assert o.es_mensaje_automatico(_h(Content_Type="multipart/report; report-type=delivery-status")) is True + + +def test_los_nombres_de_encabezado_no_distinguen_mayusculas(): + assert o.es_mensaje_automatico([{"name": "AUTO-SUBMITTED", "value": "Auto-Replied"}]) is True + + +# ============================================================================ +# send_email (payload hacia Graph) +# ============================================================================ +class _Respuesta: + def __init__(self, status=202, text=""): + self.status_code, self.text = status, text + + +def test_send_email_incluye_destinatarios_copia_y_copia_oculta(monkeypatch): + enviado = {} + monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(url=url, **k) or _Respuesta()) + + ok = o.send_email("tok", "buzon@x.com", ["a@x.com"], "Asunto", "

hola

", None, + cc_recipients=["c@x.com"], bcc_recipients=["oculto@x.com"]) + + mensaje = enviado["json"]["message"] + assert ok is True and enviado["url"].endswith("/users/buzon@x.com/sendMail") + assert mensaje["toRecipients"] == [{"emailAddress": {"address": "a@x.com"}}] + assert mensaje["ccRecipients"] == [{"emailAddress": {"address": "c@x.com"}}] + assert mensaje["bccRecipients"] == [{"emailAddress": {"address": "oculto@x.com"}}] + assert mensaje["attachments"] == [] and mensaje["body"]["contentType"] == "HTML" + + +def test_send_email_sin_copia_oculta_envia_la_lista_vacia(monkeypatch): + enviado = {} + monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(**k) or _Respuesta()) + o.send_email("tok", "b@x.com", ["a@x.com"], "A", "

") + assert enviado["json"]["message"]["bccRecipients"] == [] + + +def test_send_email_devuelve_false_si_graph_rechaza_o_falla(monkeypatch): + monkeypatch.setattr(o.requests, "post", lambda *a, **k: _Respuesta(400, "mal")) + assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "

") is False + + def explota(*a, **k): + raise ConnectionError("sin red") + monkeypatch.setattr(o.requests, "post", explota) + assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "

") is False + + +# ============================================================================ +# get_emails +# ============================================================================ +def test_get_emails_pide_los_encabezados_y_marca_los_automaticos(monkeypatch): + pedido = {} + + class Resp: + status_code = 200 + + def json(self): + return {"value": [ + {"id": "1", "receivedDateTime": "2026-10-14T15:35:00Z", "hasAttachments": True, "subject": "Factura", + "from": {"emailAddress": {"address": "a@x.com"}}, "internetMessageHeaders": []}, + {"id": "2", "receivedDateTime": "2026-10-14T16:00:00Z", "hasAttachments": False, "subject": "Auto", + "from": {"emailAddress": {"address": "b@x.com"}}, + "internetMessageHeaders": [{"name": "Auto-Submitted", "value": "auto-replied"}]}, + {"id": "3", "receivedDateTime": "2026-10-14T16:00:00Z", "subject": "Sin encabezados", "from": {}}, + ]} + + monkeypatch.setattr(o.requests, "get", lambda url, **k: pedido.update(k) or Resp()) + + correos = o.get_emails("buzon@x.com", "tok", "2026-10-14", "2026-10-14") + + assert "internetMessageHeaders" in pedido["params"]["$select"] + assert [c["es_automatico"] for c in correos] == [False, True, False] + assert correos[0]["fecha"] == "2026-10-14 10:35:00" # UTC-5 + assert correos[2]["sender"] == "Desconocido" diff --git a/tests/test_pipeline_flujo.py b/tests/test_pipeline_flujo.py index 9992188..183b442 100644 --- a/tests/test_pipeline_flujo.py +++ b/tests/test_pipeline_flujo.py @@ -1,7 +1,11 @@ """ -Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y la -BD simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de -624.38 con la guía T001-128912). +Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y Graph +simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de 624.38 +con la guía T001-128912). + +Cada factura genera dos correos: el **técnico**, solo para el equipo (asunto +"Draft SAP B1 - …"), y la **respuesta al proveedor** con la plantilla de aceptado o +rechazado (asunto "Aceptado · …" / "Rechazado · …"). """ import glob import logging @@ -21,39 +25,60 @@ from conftest import FIXTURES CASO = "001_correo" TOTAL_LINEA = 624.38 +SENDER = "proveedor@empresa.com" +EQUIPO = ["equipo1@aberio.com", "equipo2@aberio.com"] class Entorno: - """Registra lo que el pipeline le pide a los servicios externos.""" + """Registra lo que el pipeline le pide a los servicios externos y permite alterar sus respuestas.""" def __init__(self): self.line_total_sap = TOTAL_LINEA + self.guia_existe = True # False: SAP no devuelve ningún documento para la guía + self.sap_lanza = None # excepción que lanza get_delivery_notes self.error_al_crear = None + self.hana_disponible = True + self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos self.drafts = [] self.correos = [] self.descargas = 0 + @property + def tecnicos(self): + return [c for c in self.correos if c["asunto"].startswith("Draft SAP B1")] + + @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"])] + @pytest.fixture def entorno(tmp_path, monkeypatch): e = Entorno() monkeypatch.setenv("RUTA_RED_FACTURACION", "") + monkeypatch.setenv("NOTIFICACION_EQUIPO", ",".join(EQUIPO)) monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: []) - monkeypatch.setattr(database_utilities, "save_emails", lambda emails: None) def descargar(addr, id_, token, carpeta): e.descargas += 1 - os.makedirs(carpeta, exist_ok=True) # como el download_attachments real + os.makedirs(carpeta, exist_ok=True) # como el download_attachments real nombres = [] - for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")): - if ruta.lower().endswith((".pdf", ".xml")): - shutil.copy(ruta, carpeta) - nombres.append(os.path.basename(ruta)) + if e.archivos is None: + for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")): + if ruta.lower().endswith((".pdf", ".xml")): + shutil.copy(ruta, carpeta) + nombres.append(os.path.basename(ruta)) + else: + for nombre, contenido in e.archivos.items(): + with open(os.path.join(carpeta, nombre), "wb") as f: + f.write(contenido) + nombres.append(nombre) return nombres - def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None): - e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients, "adjuntos": adjuntos}) + def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None): + e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients, + "bcc": bcc_recipients, "adjuntos": adjuntos}) return True def crear_draft(draft): @@ -63,10 +88,18 @@ def entorno(tmp_path, monkeypatch): return {"DocEntry": 777} def delivery_notes(**k): + if e.sap_lanza: + raise e.sap_lanza + if not e.guia_existe: + return [] return [{"Series": "1", "DocNum": "9", "U_IDC_DESTINO": "Z1", "DocumentLines": [ {"ItemCode": "FL10007", "LineTotal": e.line_total_sap, "PriceAfterVAT": 1.3396, "Quantity": 466, "WarehouseCode": "A-CENTRA", "Weight1": 1}]}] + class ConexionHana: + def close(self): + pass + 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"}]) @@ -74,62 +107,229 @@ def entorno(tmp_path, monkeypatch): monkeypatch.setattr(sapb1_manager, "obtener_condicion_pago", lambda c: {"NumberOfAdditionalDays": 30}) monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft) monkeypatch.setattr(hana_manager, "obtener_valor_referencial", lambda s, n: 0.0) + monkeypatch.setattr(hana_manager, "get_connection", lambda: ConexionHana() if e.hana_disponible else None) e.descarga = tmp_path / "descarga" e.salida = tmp_path / "salida" return e -def _procesar(e, sender="proveedor@empresa.com"): - email = {"id": "abc", "fecha": "2026-10-14 10:00:00", "subject": "Factura", "sender": sender} - pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(e.descarga), str(e.salida)) +def _procesar(e, sender=SENDER, subject="Factura", id_="abc", es_automatico=False, carpeta=None): + email = {"id": id_, "fecha": "2026-10-14 10:35:00", "subject": subject, "sender": sender, + "has_attach": True, "es_automatico": es_automatico} + pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(carpeta or e.descarga), str(e.salida)) return email -def test_precios_coinciden_crea_draft_y_notifica(entorno): +def _registros(): + conn = sqlite3.connect(database_utilities.DB_NAME) + conn.row_factory = sqlite3.Row + try: + return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")] + finally: + conn.close() + + +# ============================================================================ +# Aceptado +# ============================================================================ +def test_aceptado_crea_draft_y_responde_con_la_plantilla(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + _procesar(entorno) assert len(entorno.drafts) == 1 draft = entorno.drafts[0] - assert draft["CardCode"] == "PL10060028686" - assert draft["NumAtCard"] == "01F002-2450" - assert draft["DocumentLines"][0]["U_IDC_GRFLETE"] == "09T001-128912" + assert draft["CardCode"] == "PL10060028686" and draft["NumAtCard"] == "01F002-2450" - assert len(entorno.correos) == 1 - correo = entorno.correos[0] - assert correo["para"] == ["proveedor@empresa.com"] - assert correo["asunto"] == "Draft SAP B1 - F002-2450" - assert "creado exitosamente" in correo["html"] and "777" in correo["html"] - assert correo["cc"] # siempre se copia al equipo - assert any(a.endswith(".xml") for a in correo["adjuntos"]) + (respuesta,) = entorno.respuestas + assert respuesta["para"] == [SENDER] # al que envió el correo + assert respuesta["bcc"] == EQUIPO # el equipo en copia oculta + assert respuesta["cc"] is None and respuesta["adjuntos"] is None + assert respuesta["asunto"].startswith("Aceptado · Factura F002-2450 · ABR-") + assert "Registramos su factura" in respuesta["html"] + assert "09T001-128912" not in respuesta["html"] and "T001-128912" in respuesta["html"] + assert "S/ 624.38" in respuesta["html"] + assert "Pago estimado" in respuesta["html"] # hay condición de pago de 30 días -def test_precios_no_coinciden_no_crea_draft_pero_notifica(entorno): +def test_el_correo_tecnico_va_solo_al_equipo_nunca_al_remitente(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + + _procesar(entorno) + + (tecnico,) = entorno.tecnicos + assert tecnico["para"] == EQUIPO + assert SENDER not in tecnico["para"] and not tecnico["cc"] and not tecnico["bcc"] + assert any(a.endswith(".xml") for a in tecnico["adjuntos"]) # lleva los adjuntos internos + assert f"Remitente: {SENDER}" in tecnico["html"] and "ABR-" in tecnico["html"] + assert SENDER not in [d for c in entorno.respuestas for d in c["para"] if c["asunto"].startswith("[")] + + +def test_modo_prueba_es_el_predeterminado_y_no_escribe_al_remitente(entorno): + _procesar(entorno) # NOTIFICACION_MODO sin definir + + (respuesta,) = entorno.respuestas + assert respuesta["para"] == EQUIPO + assert respuesta["bcc"] is None + assert respuesta["asunto"].startswith(f"[PRUEBA → {SENDER}] Aceptado · Factura F002-2450") + assert all(SENDER not in c["para"] for c in entorno.correos) # a nadie fuera del equipo + + +# ============================================================================ +# Rechazado +# ============================================================================ +def test_precios_distintos_rechaza_y_muestra_ambos_importes(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") entorno.line_total_sap = TOTAL_LINEA + 50 _procesar(entorno) assert entorno.drafts == [] - assert len(entorno.correos) == 1 - assert "Draft NO creado" in entorno.correos[0]["html"] + (respuesta,) = entorno.respuestas + assert respuesta["para"] == [SENDER] + assert respuesta["asunto"].startswith("Rechazado · Factura F002-2450") + html = respuesta["html"] + assert "No pudimos registrar su factura" in html and "1 observación" in html + assert "El importe no coincide con nuestro registro" in html + assert "S/ 624.38" in html and "S/ 674.38" in html # importe de la factura y esperado + assert "No coincide" in html -def test_error_al_crear_draft_se_notifica(entorno): +def test_guia_inexistente_rechaza_con_guia_no_encontrada(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.guia_existe = False + + _procesar(entorno) + + (respuesta,) = entorno.respuestas + assert "No encontramos la guía T001-128912" in respuesta["html"] + assert entorno.drafts == [] + + +def test_guia_inexistente_con_hana_caido_es_error_interno_y_no_se_le_culpa_al_proveedor(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.guia_existe = False + entorno.hana_disponible = False + + _procesar(entorno) + + assert entorno.respuestas == [] # nada al proveedor + (tecnico,) = entorno.tecnicos + assert "HANA" in tecnico["html"] + (registro,) = _registros() + assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida" + + +def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.sap_lanza = RuntimeError("sin conexión") + + _procesar(entorno) + + assert entorno.respuestas == [] and entorno.drafts == [] + assert len(entorno.tecnicos) == 1 + assert _registros()[0]["estado"] == "error" + + +def test_error_al_crear_draft_avisa_solo_al_equipo(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") entorno.error_al_crear = RuntimeError("Service Layer caído") _procesar(entorno) - assert entorno.drafts == [] - assert len(entorno.correos) == 1 - assert "Error al crear Draft" in entorno.correos[0]["html"] - assert "Service Layer caído" in entorno.correos[0]["html"] + assert entorno.respuestas == [] + (tecnico,) = entorno.tecnicos + assert "Error al crear Draft" in tecnico["html"] and "Service Layer caído" in tecnico["html"] + (registro,) = _registros() + assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida" -def test_remitente_desconocido_no_envia_correo(entorno): +# ============================================================================ +# Correos sin comprobante válido +# ============================================================================ +def test_correo_con_adjuntos_que_no_son_pdf_ni_xml_se_rechaza(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {"foto.png": b"x", "nota.docx": b"y"} + + _procesar(entorno) + + (respuesta,) = entorno.respuestas + assert respuesta["para"] == [SENDER] + assert respuesta["asunto"].startswith("Rechazado · Comprobante no recibido · ABR-") + html = respuesta["html"] + assert "No recibimos el XML ni el PDF de su comprobante" in html + assert "Guías revisadas" not in html # sin tabla de guías vacía + assert "RUC —" not in html and "Factura electrónica" not in html # sin datos de comprobante inventados + assert html.count("RUC") == 1 # solo el del pie (RUC de A. Berio) + assert "{{" not in html + assert entorno.tecnicos == [] and entorno.drafts == [] + (registro,) = _registros() + assert registro["serie"] is None and registro["estado"] == "rechazado" + assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER + + +def test_solo_pdf_sin_xml_pide_el_xml(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + pdf = glob.glob(os.path.join(FIXTURES, "002_correo", "*.pdf"))[0] + entorno.archivos = {os.path.basename(pdf): open(pdf, "rb").read()} + + _procesar(entorno) + + (respuesta,) = entorno.respuestas + assert "Falta el archivo XML de su comprobante" in respuesta["html"] + + +def test_correo_sin_adjuntos_no_se_responde(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {} + + _procesar(entorno) + + assert entorno.correos == [] and _registros() == [] + + +@pytest.mark.parametrize("sender, subject, automatico, motivo", [ + ("no-reply@empresa.com", "Factura", False, "automática"), + ("buzon@aberio.com", "Factura", False, "propio buzón"), + ("persona@local", "Factura", False, "no válida"), + (SENDER, "Fuera de oficina", True, "automático"), + (SENDER, "Re: Aceptado · Factura F002-1 · ABR-261019-00042", False, "respuesta a una notificación"), + (SENDER, "RE: gracias", False, "respuesta a una notificación"), +]) +def test_correos_sin_comprobante_a_los_que_no_se_responde(entorno, monkeypatch, sender, subject, automatico, motivo): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {"foto.png": b"x"} + + _procesar(entorno, sender=sender, subject=subject, es_automatico=automatico) + + assert entorno.correos == [] + (registro,) = _registros() + assert registro["notificacion_estado"] == "omitida" and motivo in registro["notificacion_error"] + + +def test_limite_de_respuestas_por_remitente(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {"foto.png": b"x"} + + for n in range(4): + _procesar(entorno, id_=f"correo-{n}", carpeta=entorno.descarga.parent / f"d{n}") + + assert [r["notificacion_estado"] for r in _registros()] == ["enviada", "enviada", "enviada", "omitida"] + assert len(entorno.respuestas) == 3 + + +# ============================================================================ +# Remitente y registro +# ============================================================================ +def test_remitente_desconocido_crea_el_draft_pero_no_responde(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + _procesar(entorno, sender="Desconocido") - assert len(entorno.drafts) == 1 # el draft se crea igual - assert entorno.correos == [] + assert len(entorno.drafts) == 1 + assert entorno.respuestas == [] and len(entorno.tecnicos) == 1 + assert _registros()[0]["notificacion_estado"] == "omitida" def test_correo_ya_procesado_se_salta(entorno, monkeypatch): @@ -137,8 +337,7 @@ def test_correo_ya_procesado_se_salta(entorno, monkeypatch): _procesar(entorno) - assert entorno.descargas == 0 - assert entorno.drafts == [] and entorno.correos == [] + assert entorno.descargas == 0 and entorno.drafts == [] and entorno.correos == [] def test_guarda_resultados_y_draft_en_disco(entorno): @@ -149,69 +348,50 @@ def test_guarda_resultados_y_draft_en_disco(entorno): assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres) -# ============================================================================ -# Seguimiento -# ============================================================================ -def _registros(): - conn = sqlite3.connect(database_utilities.DB_NAME) - conn.row_factory = sqlite3.Row - try: - return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")] - finally: - conn.close() +def test_aceptado_deja_el_resultado_y_la_notificacion_registrados(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") - -def test_draft_creado_deja_el_comprobante_aceptado(entorno): _procesar(entorno) (registro,) = _registros() - assert registro["estado"] == "aceptado" - assert registro["doc_entry"] == 777 - assert registro["codigo_seguimiento"].startswith("ABR-") - assert (registro["serie"], registro["numero"], registro["tipo"]) == ("F002", "2450", "01") - assert registro["ruc_emisor"] == "10060028686" + assert (registro["estado"], registro["doc_entry"], registro["tipo"]) == ("aceptado", 777, "01") + assert (registro["serie"], registro["numero"], registro["ruc_emisor"]) == ("F002", "2450", "10060028686") + assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER -def test_precios_que_no_coinciden_dejan_el_comprobante_rechazado(entorno): +def test_modo_prueba_registra_a_quien_se_envio_de_verdad(entorno): + _procesar(entorno) + + assert _registros()[0]["notificacion_destinatario"] == ", ".join(EQUIPO) + + +def test_rechazo_guarda_las_observaciones_completas(entorno): entorno.line_total_sap = TOTAL_LINEA + 50 _procesar(entorno) (registro,) = _registros() - assert registro["estado"] == "rechazado" - assert registro["doc_entry"] is None - assert seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] == [{"codigo": "precios_no_coinciden"}] - - -def test_error_al_crear_draft_deja_el_comprobante_en_error(entorno): - entorno.error_al_crear = RuntimeError("Service Layer caído") - - _procesar(entorno) - - (registro,) = _registros() - assert registro["estado"] == "error" - obs = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] - assert obs == [{"codigo": "error_sap", "detalle": "Service Layer caído"}] + (observacion,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] + assert observacion["codigo"] == "precio_no_coincide" + assert {"etiqueta": "Importe esperado", "valor": "S/ 674.38"} in observacion["filas"] def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog): _procesar(entorno) (primero,) = _registros() - entorno.descarga = entorno.descarga.parent / "descarga2" # otra descarga, otro correo with caplog.at_level(logging.WARNING, logger="pipeline"): - pipeline._procesar_un_correo( - {"id": "otro-correo", "fecha": "2026-10-19 11:00:00", "subject": "Reenvío", "sender": "p@e.com"}, - "token", "buzon@aberio.com", str(entorno.descarga), str(entorno.salida), - ) + _procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2") - (registro,) = _registros() # sigue habiendo una sola fila + (registro,) = _registros() # sigue habiendo una sola fila assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"] assert registro["veces_recibido"] == 2 assert any("ya recibido antes" in r.getMessage() for r in caplog.records) -def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch): +def test_si_falla_el_seguimiento_igual_se_crea_el_draft_pero_no_se_responde(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + def roto(*a, **k): raise sqlite3.OperationalError("database is locked") @@ -221,4 +401,15 @@ def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch): _procesar(entorno) assert len(entorno.drafts) == 1 - assert len(entorno.correos) == 1 + assert len(entorno.tecnicos) == 1 and entorno.respuestas == [] # sin código no se responde al proveedor + + +def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + monkeypatch.setattr(pipeline.draft_sap, "build_sap_draft", lambda *a, **k: 1 / 0) + + _procesar(entorno) # no debe lanzar excepción + + assert entorno.drafts == [] and entorno.respuestas == [] + assert "ZeroDivisionError" in entorno.tecnicos[0]["html"] + assert _registros()[0]["estado"] == "error" diff --git a/tests/test_respuesta_proveedor.py b/tests/test_respuesta_proveedor.py new file mode 100644 index 0000000..70ac9a5 --- /dev/null +++ b/tests/test_respuesta_proveedor.py @@ -0,0 +1,270 @@ +import logging + +import pytest + +import observaciones as obs +import outlook_utilities +import plantillas +import respuesta_proveedor as rp +import seguimiento + +FACTURA = { + "serie": "F002", "numero": "486", "tipo": "01", "ruc_emisor": "20537929520", + "razon_social_emisor": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.", + "lineas": [ + {"total_linea": 624.38, "guias_remision": [{"serie": "T001", "numero": "1"}, {"serie": "T001", "numero": "2"}]}, + {"total_linea": 100.0, "guias_remision": [{"serie": "T001", "numero": "3"}]}, + ], +} +RESULTADOS = [ + {"ok": True, "guias_lt": [{"guia": {"serie": "T001", "numero": "1"}}, {"guia": {"serie": "T001", "numero": "2"}}]}, + {"ok": False, "guias_lt": [{"guia": {"serie": "T001", "numero": "3"}}]}, +] +EMAIL = {"id": "c1", "fecha": "2026-10-14 10:35:00", "subject": "Factura", "sender": "prov@empresa.com"} + + +@pytest.fixture +def registro(): + return seguimiento.registrar_comprobante("c1", "20537929520", "01", "F002", "486") + + +# ============================================================================ +# Configuración +# ============================================================================ +@pytest.mark.parametrize("valor, esperado", [(None, "prueba"), ("", "prueba"), ("real", "real"), (" REAL ", "real"), + ("prueba", "prueba"), ("produccion", "prueba")]) +def test_modo(monkeypatch, valor, esperado): + if valor is not None: + monkeypatch.setenv("NOTIFICACION_MODO", valor) + assert rp.modo() == esperado + + +def test_equipo(monkeypatch): + assert rp.equipo() == rp.EQUIPO_POR_DEFECTO + monkeypatch.setenv("NOTIFICACION_EQUIPO", " a@x.com ; b@x.com,c@x.com ,, ") + assert rp.equipo() == ["a@x.com", "b@x.com", "c@x.com"] + + +def test_contacto_usa_el_buzon_si_no_se_define(monkeypatch): + assert rp.contacto_correo() == "buzon@aberio.com" + monkeypatch.setenv("NOTIFICACION_CONTACTO", "ayuda@aberio.com") + assert rp.contacto_correo() == "ayuda@aberio.com" + + +# ============================================================================ +# Protecciones +# ============================================================================ +BUZON = "buzon@aberio.com" + + +def _correo(**kv): + return {**EMAIL, **kv} + + +@pytest.mark.parametrize("sender", [None, "", "Desconocido"]) +def test_sin_remitente(sender): + assert "desconocido" in rp.motivo_omision(_correo(sender=sender), BUZON) + + +@pytest.mark.parametrize("sender", ["test@local", "sin-arroba", "a@b", "a b@c.com"]) +def test_direcciones_no_validas(sender): + assert "no válida" in rp.motivo_omision(_correo(sender=sender), BUZON) + + +def test_el_propio_buzon_aunque_cambie_la_capitalizacion(): + assert "propio buzón" in rp.motivo_omision(_correo(sender="Buzon@ABERIO.com"), BUZON) + + +@pytest.mark.parametrize("sender", ["no-reply@x.com", "noreply@x.com", "No_Reply@x.com", "donotreply@x.com", + "do-not-reply@x.com", "mailer-daemon@x.com", "postmaster@x.com", "bounces@x.com", + "no-responder@x.com"]) +def test_direcciones_automaticas(sender): + assert "automática" in rp.motivo_omision(_correo(sender=sender), BUZON) + + +def test_una_direccion_normal_con_palabras_parecidas_si_se_responde(): + assert rp.motivo_omision(_correo(sender="reply.compras@empresa.com"), BUZON) is None + assert rp.motivo_omision(_correo(sender="ventas@empresa.com"), BUZON) is None + + +def test_mensaje_automatico(): + assert "automático" in rp.motivo_omision(_correo(es_automatico=True), BUZON) + + +def test_respuestas_a_notificaciones_solo_se_omiten_si_no_hay_comprobante(): + respuesta = _correo(subject="Re: Aceptado · Factura F002-1 · ABR-261019-00042") + assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=True) is not None + assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=False) is None # con comprobante se procesa normal + assert rp.motivo_omision(_correo(subject="RV: documentos"), BUZON, sin_comprobante=True) is not None + assert rp.motivo_omision(_correo(subject="Factura ABR-261019-00042"), BUZON, sin_comprobante=True) is not None + assert rp.motivo_omision(_correo(subject="Factura de octubre"), BUZON, sin_comprobante=True) is None + + +# ============================================================================ +# Contextos +# ============================================================================ +def test_guias_el_importe_de_la_linea_va_una_vez(): + filas = rp.guias_para_plantilla(FACTURA, RESULTADOS) + + assert filas == [ + {"guia": "T001-1", "importe_factura": "S/ 624.38", "conforme": True}, + {"guia": "T001-2", "importe_factura": "incluida en la línea", "conforme": True}, + {"guia": "T001-3", "importe_factura": "S/ 100.00", "conforme": False}, + ] + + +def test_guias_sin_resultado_de_validacion_usa_las_del_xml_como_no_conformes(): + filas = rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": [{"serie": "T001", "numero": "9"}]}]}, []) + assert filas == [{"guia": "T001-9", "importe_factura": "S/ 5.00", "conforme": False}] + + +def test_linea_sin_guias_no_genera_filas(): + assert rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": []}]}, [{"ok": False, "guias_lt": []}]) == [] + + +@pytest.mark.parametrize("vence, esperado", [("2026-11-13", "13/11/2026"), ("2026-10-14", None), ("2026-10-01", None)]) +def test_fecha_estimada_de_pago_solo_si_hay_plazo(registro, vence, esperado): + draft = {"DocDate": "2026-10-14", "DocDueDate": vence} + contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, draft) + assert contexto["fecha_estimada_pago"] == esperado + + +def test_contexto_aceptado(registro): + contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {}) + + assert contexto["asunto"] == f"Aceptado · Factura F002-486 · {registro['codigo_seguimiento']}" + assert contexto["recepcion_fecha"] == "14/10/2026" and contexto["recepcion_hora"] == "10:35" + assert contexto["comprobante_tipo"] == "Factura electrónica" and contexto["proveedor_ruc"] == "20537929520" + assert contexto["fecha_estimada_pago"] is None + + +def test_contexto_rechazo_numera_las_observaciones(registro): + lista = [obs.guia_no_encontrada("T001-3"), obs.precio_no_coincide(2, 100, 120, ["T001-3"], True)] + contexto = rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista) + + assert contexto["observaciones_texto"] == "2 observaciones" + assert [o["numero"] for o in contexto["observaciones"]] == [1, 2] + assert contexto["observaciones"][0]["filas"] == [{"etiqueta": "Guía", "valor": "T001-3"}] + assert contexto["tiene_guias"] is True and contexto["preheader"].startswith("Tiene 2 observaciones.") + + +def test_contexto_rechazo_de_correo_sin_comprobante(registro): + contexto = rp.contexto_rechazo_correo(EMAIL, registro, [obs.sin_pdf_xml(1)]) + + assert contexto["asunto"] == f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}" + assert contexto["comprobante_numero"] is None and contexto["proveedor_ruc"] is None + assert contexto["proveedor_razon_social"] == "prov@empresa.com" + assert contexto["guias"] == [] and contexto["tiene_guias"] is False + + +def test_los_tres_contextos_renderizan_sin_variables_faltantes(registro): + lista = [obs.sin_pdf_xml(1)] + for plantilla, contexto in [ + ("aceptado", rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {"DocDate": "2026-10-14", "DocDueDate": "2026-11-13"})), + ("rechazado", rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista)), + ("rechazado", rp.contexto_rechazo_correo(EMAIL, registro, lista)), + ]: + html = plantillas.renderizar_plantilla(plantilla, contexto) + assert "{{" not in html and registro["codigo_seguimiento"] in html + + +def test_razon_social_con_ampersand_sale_escapada(registro): + contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {}) + assert "SÁNCHEZ & HIJOS" in plantillas.renderizar_plantilla("aceptado", contexto) + + +# ============================================================================ +# responder() +# ============================================================================ +class _Enviados(list): + """Correos que recibió Graph; ``ok`` controla si Graph los acepta.""" + ok = True + + +@pytest.fixture +def graph(monkeypatch): + enviados = _Enviados() + + def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None): + enviados.append({"para": para, "bcc": bcc_recipients, "asunto": asunto}) + return enviados.ok + + monkeypatch.setattr(outlook_utilities, "send_email", enviar) + monkeypatch.setenv("NOTIFICACION_EQUIPO", "eq@aberio.com") + return enviados + + +def _contexto(registro): + return rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {}) + + +def test_responder_en_modo_real(graph, registro, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + + assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada" + + assert graph == [{"para": ["prov@empresa.com"], "bcc": ["eq@aberio.com"], "asunto": _contexto(registro)["asunto"]}] + fila = seguimiento.obtener(registro["codigo_seguimiento"]) + assert (fila["notificacion_estado"], fila["notificacion_destinatario"]) == ("enviada", "prov@empresa.com") + + +def test_responder_en_modo_prueba(graph, registro): + assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada" + + (envio,) = graph + assert envio["para"] == ["eq@aberio.com"] and envio["bcc"] is None + assert envio["asunto"].startswith("[PRUEBA → prov@empresa.com] Aceptado") + + +def test_responder_omite_y_anota_el_motivo(graph, registro): + estado = rp.responder("t", BUZON, _correo(sender="no-reply@x.com"), "aceptado", _contexto(registro), registro) + + assert estado == "omitida" and graph == [] + fila = seguimiento.obtener(registro["codigo_seguimiento"]) + assert fila["notificacion_estado"] == "omitida" and "automática" in fila["notificacion_error"] + + +def test_responder_si_graph_falla(graph, registro): + graph.ok = False + + assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "fallida" + + fila = seguimiento.obtener(registro["codigo_seguimiento"]) + assert fila["notificacion_estado"] == "fallida" and "Graph" in fila["notificacion_error"] + + +def test_responder_si_la_plantilla_no_se_puede_armar_no_envia_nada(graph, registro): + contexto = _contexto(registro) + del contexto["codigo_seguimiento"] + + assert rp.responder("t", BUZON, EMAIL, "aceptado", contexto, registro) == "fallida" + + assert graph == [] + assert "codigo_seguimiento" in seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_error"] + + +def test_el_limite_solo_aplica_a_correos_sin_comprobante(graph, monkeypatch): + import database_utilities + monkeypatch.setenv("NOTIFICACION_MODO", "real") + database_utilities.save_emails([{"id": f"e{n}", "fecha": "2026-10-14 10:00:00", "sender": "prov@empresa.com", + "subject": "s", "has_attach": True} for n in range(5)]) + + estados = [] + for n in range(5): + reg = seguimiento.registrar_correo(f"e{n}") + email = _correo(id=f"e{n}") + estados.append(rp.responder("t", BUZON, email, "rechazado", rp.contexto_rechazo_correo(email, reg, [obs.sin_pdf_xml(1)]), + reg, sin_comprobante=True)) + + assert estados == ["enviada"] * rp.MAX_RESPUESTAS_CORREO + ["omitida"] * (5 - rp.MAX_RESPUESTAS_CORREO) + + +def test_encabezado_tecnico(registro): + html = rp.encabezado_tecnico(EMAIL, registro) + assert "prov@empresa.com" in html and registro["codigo_seguimiento"] in html and "prueba" in html + assert "sin código" in rp.encabezado_tecnico(EMAIL, None) + + +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"] diff --git a/tests/test_seguimiento.py b/tests/test_seguimiento.py index 0bf448e..182fcce 100644 --- a/tests/test_seguimiento.py +++ b/tests/test_seguimiento.py @@ -147,3 +147,66 @@ def test_la_tabla_emails_existente_no_se_modifica(): database_utilities.init_db() assert seguimiento.registrar_correo("correo-1")["codigo_seguimiento"].startswith("ABR-") + + +def test_registrar_notificacion_guarda_el_destinatario(): + r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE) + seguimiento.registrar_notificacion(r["codigo_seguimiento"], "enviada", destinatario="a@x.com, b@x.com") + assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_destinatario"] == "a@x.com, b@x.com" + + +def _correo_con_respuesta(email_id, sender, estado="enviada", comprobante=False): + database_utilities.save_emails([{"id": email_id, "fecha": "2026-10-14 10:00:00", "sender": sender, + "subject": "s", "has_attach": True}]) + r = (seguimiento.registrar_comprobante(email_id, *COMPROBANTE[:2], "F002", email_id) if comprobante + else seguimiento.registrar_correo(email_id)) + seguimiento.registrar_notificacion(r["codigo_seguimiento"], estado) + return r + + +def test_contar_respuestas_solo_cuenta_enviadas_a_correos_sin_comprobante_de_ese_remitente(): + _correo_con_respuesta("e1", "Prov@Empresa.com") # cuenta (sin distinguir mayúsculas) + _correo_con_respuesta("e2", "prov@empresa.com") # cuenta + _correo_con_respuesta("e3", "prov@empresa.com", estado="omitida") # no: no se envió + _correo_con_respuesta("e4", "prov@empresa.com", estado="fallida") # no: no se envió + _correo_con_respuesta("e5", "otro@empresa.com") # no: otro remitente + _correo_con_respuesta("e6", "prov@empresa.com", comprobante=True) # no: tiene comprobante + + assert seguimiento.contar_respuestas_correo("prov@empresa.com") == 2 + assert seguimiento.contar_respuestas_correo("nadie@empresa.com") == 0 + + +def test_contar_respuestas_ignora_las_anteriores_a_la_ventana(): + r = _correo_con_respuesta("e1", "prov@empresa.com") + conn = sqlite3.connect(database_utilities.DB_NAME) + conn.execute("UPDATE comprobantes SET notificacion_fecha = '2020-01-01 00:00:00' WHERE codigo_seguimiento = ?", + (r["codigo_seguimiento"],)) + conn.commit() + conn.close() + + assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24) == 0 + assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24 * 365 * 10) == 1 + + +def test_init_db_agrega_la_columna_a_una_tabla_creada_antes(): + 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, + 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")["email_id"] == "viejo" # el dato se conserva + seguimiento.registrar_notificacion("ABR-261001-00001", "enviada", destinatario="a@x.com") + assert seguimiento.obtener("ABR-261001-00001")["notificacion_destinatario"] == "a@x.com" +