se responde al remitente con las plantillas de aceptado/rechazado

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 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 08:55:54 -05:00
1 parent af9e3961e5
commit 4deb9d4e4d
22 files changed
+1660 -146

No files matched your search

+12
View File
@@ -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 =
+6 -2
View File
@@ -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 |
+15
View File
@@ -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")
+6
View File
@@ -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
+10 -7
View File
@@ -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).
---
+5 -3
View File
@@ -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.
+35 -9
View File
@@ -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<br>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<br>+ respuesta con plantilla al remitente]
V --> W
X --> W2[16. Correo técnico al equipo<br>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.
+92
View File
@@ -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.
+11 -4
View File
@@ -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
+30 -1
View File
@@ -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)
+157
View File
@@ -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
+29 -4
View File
@@ -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"
+104 -32
View File
@@ -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")
# ============================================================================
+285
View File
@@ -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"<p><b>Remitente:</b> {email.get('sender', '')}<br>"
f"<b>Asunto:</b> {email.get('subject', '')}<br>"
f"<b>Código de seguimiento:</b> {codigo}<br>"
f"<b>Modo de notificación:</b> {modo()}</p>"
)
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)
+23 -4
View File
@@ -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]
+11
View File
@@ -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")
+31 -1
View File
@@ -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)
+98
View File
@@ -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"]}
+97
View File
@@ -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", "<p>hola</p>", 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", "<p/>")
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", "<p/>") 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", "<p/>") 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"
+270 -79
View File
@@ -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"<b>Remitente:</b> {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"
+270
View File
@@ -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 &amp; 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", "<p/>", ["a.xml"]) is True
assert graph[0]["para"] == ["eq@aberio.com"]
+63
View File
@@ -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"