diff --git a/.env.example b/.env.example
index 1a9cc72..f2cf8b1 100644
--- a/.env.example
+++ b/.env.example
@@ -26,6 +26,7 @@ HANA_PASSWORD =
HANA_SCHEMA =
# --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) ---
+# Cómo obtener estos valores: docs/configurar_msal.md
CLIENT_ID =
CLIENT_SECRET =
TENANT_ID =
@@ -33,3 +34,14 @@ VERIFY_SSL = false
# --- Logging (ver logger_manager.py). Opcional, por defecto INFO ---
LOG_LEVEL = INFO
+
+# --- Respuesta al proveedor (aceptado / rechazado), ver docs/respuesta_proveedor.md ---
+# prueba (por defecto): la respuesta va al equipo con asunto "[PRUEBA → remitente]", NO al proveedor.
+# real: la respuesta va a quien envió el correo, con copia oculta al equipo.
+NOTIFICACION_MODO = prueba
+# Direcciones internas (coma o punto y coma): reciben el correo técnico y las respuestas en modo prueba.
+NOTIFICACION_EQUIPO = equipo1@dominio.com,equipo2@dominio.com
+# Contacto del botón "Escribir a Aberio" (por defecto, el buzón EMAIL).
+NOTIFICACION_CONTACTO =
+# URL pública (https) del logo que se muestra en el correo. Vacío = sin logo.
+LOGO_URL =
diff --git a/README.md b/README.md
index 862c6dc..f3fac2f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ Bandeja de entrada (Microsoft Graph)
→ extrae datos de PDF y XML y asigna las guías de remisión por línea
→ valida precios de cada guía contra SAP (Service Layer, con fallback a HANA)
→ crea el Draft en SAP B1 si todos los precios coinciden
- → envía correo con el resultado (Draft creado / error / precios no coinciden)
+ → responde a quien envió el correo con la plantilla de aceptado o rechazado
+ (por defecto en modo prueba: la respuesta va al equipo; ver docs/respuesta_proveedor.md)
```
Cada correo se registra en una base SQLite (`emails.db`) y no se vuelve a procesar. Detalle del flujo en [`docs/pipeline.md`](docs/pipeline.md).
@@ -70,7 +71,8 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
| `main.py` | Demonio y horarios |
| `pipeline.py` | Orquesta el proceso de cada correo |
| `adjuntos.py`, `documentos.py`, `persistencia.py` | Descarga de adjuntos, extracción de PDF/XML, guardado de JSON |
-| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; cuerpo de los correos |
+| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; correo técnico para el equipo |
+| `respuesta_proveedor.py`, `observaciones.py`, `plantillas.py` | Respuesta de aceptado/rechazado al remitente con las plantillas HTML (`plantilla/`) |
| `outlook_utilities.py`, `sapb1_manager.py`, `hana_manager.py` | Clientes de Microsoft Graph, SAP Service Layer y SAP HANA |
| `sunat_schema.py`, `pdf_utilities.py` | Lectura de XML SUNAT y de PDF |
| `database_utilities.py`, `seguimiento.py`, `email_utilities.py` | SQLite local; códigos de seguimiento y resultado por comprobante; ZIP y filtrado de adjuntos |
@@ -88,6 +90,8 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
| [`docs/procesar_pdfs.md`](docs/procesar_pdfs.md) / [`docs/procesar_xmls.md`](docs/procesar_xmls.md) | Extracción de datos de PDF y XML |
| [`docs/validar_precios.md`](docs/validar_precios.md) | Validación de precios contra SAP |
| [`docs/construir_draft.md`](docs/construir_draft.md) | Construcción del Draft SAP B1 |
+| [`docs/respuesta_proveedor.md`](docs/respuesta_proveedor.md) | Respuesta de aceptado/rechazado al proveedor: modos, protecciones y observaciones |
| [`docs/seguimiento.md`](docs/seguimiento.md) | Códigos de seguimiento y resultado por comprobante |
+| [`docs/configurar_msal.md`](docs/configurar_msal.md) | Cómo obtener `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET` en Azure |
| [`docs/outlook_utilities.md`](docs/outlook_utilities.md) | Cliente de Microsoft Graph |
| [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA |
diff --git a/config_validator.py b/config_validator.py
index 7edc2dc..26b786b 100644
--- a/config_validator.py
+++ b/config_validator.py
@@ -21,6 +21,9 @@ OBLIGATORIAS = {
"SAP HANA": ["HANA_SERVER", "HANA_USER", "HANA_PASSWORD", "HANA_SCHEMA"],
}
+# Modos de ``NOTIFICACION_MODO``: ver respuesta_proveedor.py
+MODOS_NOTIFICACION = ("prueba", "real")
+
# Variable de verificación SSL → servicio al que aplica. Cada módulo solo
# la activa con el valor "true" (cualquier otro valor, o ausente, la desactiva).
VERIFICACION_SSL = {
@@ -55,6 +58,18 @@ def validar_configuracion() -> tuple[list[str], list[str]]:
"y U_VS_ANEXO_PDF/XML irán vacíos en el draft"
)
+ modo = os.getenv("NOTIFICACION_MODO", "").strip().lower()
+ if modo and modo not in MODOS_NOTIFICACION:
+ advertencias.append(f"NOTIFICACION_MODO='{modo}' no es válido (usa {' o '.join(MODOS_NOTIFICACION)}); se usará prueba")
+ elif modo == "real":
+ advertencias.append("Modo de notificación REAL: las respuestas se envían a quien envía el correo")
+ else:
+ advertencias.append("Modo de notificación PRUEBA: las respuestas van al equipo, no a quien envía el correo")
+ if not os.getenv("NOTIFICACION_EQUIPO", "").strip():
+ advertencias.append("NOTIFICACION_EQUIPO no definida: se usa la lista por defecto del equipo")
+ if not os.getenv("LOGO_URL", "").strip():
+ advertencias.append("LOGO_URL no definida: los correos al proveedor saldrán sin logo")
+
nivel = os.getenv("LOG_LEVEL", "").strip()
if nivel and not isinstance(getattr(logging, nivel.upper(), None), int):
advertencias.append(f"LOG_LEVEL='{nivel}' no es un nivel válido; se usará INFO")
diff --git a/database_utilities.py b/database_utilities.py
index 2ec14b4..01f49dc 100644
--- a/database_utilities.py
+++ b/database_utilities.py
@@ -47,10 +47,16 @@ def init_db() -> None:
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente',
notificacion_error TEXT,
notificacion_fecha TEXT,
+ notificacion_destinatario TEXT,
creado_en TEXT NOT NULL,
actualizado_en TEXT NOT NULL
)
''')
+ # Bases creadas antes de agregar una columna: se añade sin tocar los datos.
+ columnas = {fila[1] for fila in cursor.execute("PRAGMA table_info(comprobantes)")}
+ if "notificacion_destinatario" not in columnas:
+ cursor.execute("ALTER TABLE comprobantes ADD COLUMN notificacion_destinatario TEXT")
+
cursor.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento
ON comprobantes (ruc_emisor, tipo, serie, numero) WHERE serie IS NOT NULL
diff --git a/docs/inventario.md b/docs/inventario.md
index d7b9f7a..5ad6ebc 100644
--- a/docs/inventario.md
+++ b/docs/inventario.md
@@ -5,27 +5,30 @@
| Archivo | Líneas | Rol |
|---------|--------|-----|
| `main.py` | 137 | Demonio orquestador (loop de horarios) |
-| `pipeline.py` | 272 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
+| `pipeline.py` | 344 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
| `pipeline_utils.py` | 27 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
| `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML |
| `documentos.py` | 372 | Extracción PDF/XML, asignación de guías, copia a red |
| `persistencia.py` | 163 | Guardado de JSON de resultados y de drafts |
-| `draft_sap.py` | 414 | Validación de precios contra SAP, valor referencial y construcción del draft |
+| `draft_sap.py` | 442 | Validación de precios contra SAP, valor referencial y construcción del draft |
| `notificaciones.py` | 117 | Cuerpos HTML de los correos de notificación |
| `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
| `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) |
| `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer |
-| `outlook_utilities.py` | 249 | Comunicación con Microsoft Graph |
+| `outlook_utilities.py` | 274 | Comunicación con Microsoft Graph |
| `hana_manager.py` | 141 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) |
-| `database_utilities.py` | 150 | SQLite local (cache de correos) |
+| `database_utilities.py` | 156 | SQLite local (cache de correos) |
| `tools/*_prueba.py`, `tools/completar_guias_pipeline.py`, `tools/explorar_notes_xml.py` | — | Scripts manuales sobre `tests/fixtures/correos_prueba/` (anteriores a la suite pytest) |
| `tools/consulta_rapida.py` | 120 | Script auxiliar para reprocesar correos por ID (sets en `tools/ids_prueba.json`) |
-| `seguimiento.py` | 187 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) |
-| `config_validator.py` | 83 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) |
+| `seguimiento.py` | 206 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) |
+| `respuesta_proveedor.py` | 285 | Respuesta de aceptado/rechazado al remitente (modo prueba/real, protecciones, contextos) |
+| `observaciones.py` | 157 | Catálogo de motivos de rechazo y sus textos |
+| `plantillas.py` | 123 | Renderizador de las plantillas HTML de correo |
+| `config_validator.py` | 98 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) |
| `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) |
| `logger_manager.py` | 150 | Logging a archivo rotado (30 días) + consola + captura de `print()` |
-Documentación por módulo: `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md).
+Documentación por módulo: `respuesta_proveedor.py`/`observaciones.py`/`plantillas.py` → [respuesta_proveedor.md](respuesta_proveedor.md), `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md).
---
diff --git a/docs/outlook_utilities.md b/docs/outlook_utilities.md
index 2d986c4..8b0d823 100644
--- a/docs/outlook_utilities.md
+++ b/docs/outlook_utilities.md
@@ -1,6 +1,6 @@
# outlook_utilities.py — Microsoft Graph (Outlook)
-Cliente mínimo de Microsoft Graph para leer la bandeja de entrada, bajar adjuntos y enviar las notificaciones. Autenticación con MSAL (client credentials, permisos de aplicación).
+Cliente mínimo de Microsoft Graph para leer la bandeja de entrada, bajar adjuntos y enviar las notificaciones. Autenticación con MSAL (client credentials, permisos de aplicación). Cómo obtener las credenciales: [configurar_msal.md](configurar_msal.md).
## Estructura
@@ -19,9 +19,9 @@ outlook_utilities.py
| Función | Endpoint Graph | Qué hace |
|---------|----------------|----------|
| `get_access_token` | `login.microsoftonline.com/{TENANT_ID}` (scope `.default`) | Obtiene el token de aplicación. Lanza `Exception` si MSAL devuelve error. |
-| `get_emails` | `GET /users/{user}/mailFolders/Inbox/messages` | Correos de la bandeja de entrada del rango, con paginación (`$top=50`, sigue `@odata.nextLink`). Devuelve `id`, `fecha`, `sender`, `subject`, `has_attach`. Lanza `Exception` si el status no es 200. |
+| `get_emails` | `GET /users/{user}/mailFolders/Inbox/messages` | Correos de la bandeja de entrada del rango, con paginación (`$top=50`, sigue `@odata.nextLink`). Devuelve `id`, `fecha`, `sender`, `subject`, `has_attach` y `es_automatico` (calculado con los encabezados del mensaje). Lanza `Exception` si el status no es 200. |
| `download_attachments` | `GET /users/{user}/messages/{id}/attachments` | Guarda en `download_path` los adjuntos tipo archivo y devuelve sus nombres. Crea la carpeta si no existe. |
-| `send_email` | `POST /users/{from}/sendMail` | Envía HTML con adjuntos y CC; guarda copia en Enviados. Devuelve `True` si Graph responde 202; `False` (con `ERROR` en el log) en cualquier otro caso. No lanza excepciones. |
+| `send_email` | `POST /users/{from}/sendMail` | Envía HTML con adjuntos, CC y copia oculta (`bcc_recipients`); guarda copia en Enviados. Devuelve `True` si Graph responde 202; `False` (con `ERROR` en el log) en cualquier otro caso. No lanza excepciones. |
## Zona horaria
@@ -38,3 +38,5 @@ Los adjuntos se leen de las rutas indicadas. Una ruta inexistente se omite con u
- `VERIFY_SSL` se respeta en `get_access_token`, `get_emails` y `download_attachments`, pero `send_email` tiene `verify_ssl = False` fijo y lo ignora.
- El módulo ejecuta `warnings.filterwarnings("ignore")`, lo que silencia todas las advertencias de Python del proceso (incluidas las de certificado SSL).
- `get_access_token` retorna `None` (sin lanzar error) si MSAL devuelve un resultado vacío.
+- `es_mensaje_automatico(headers)` marca como automáticos los mensajes con `Auto-Submitted` distinto de `no`, `X-Auto-Response-Suppress`, `X-Autoreply`, `Precedence` (`bulk`, `junk`, `list`, `auto_reply`) o `Content-Type` de informe de entrega (rebotes). El sistema no responde a esos mensajes.
+
diff --git a/docs/pipeline.md b/docs/pipeline.md
index 5c88b5c..53bf07b 100644
--- a/docs/pipeline.md
+++ b/docs/pipeline.md
@@ -14,7 +14,10 @@ Módulos que orquesta _procesar_un_correo():
├── documentos.py procesar_pdfs, procesar_xmls, completar_guias, validar_guias, copiar_a_red
├── persistencia.py guardar_resultado, guardar_draft_json
├── draft_sap.py validate_prices, obtener_valor_referencial_sap, build_sap_draft
-├── notificaciones.py build_validation_html, build_valor_referencial_html, build_notification_body
+├── notificaciones.py build_validation_html, build_valor_referencial_html, build_notification_body (correo técnico)
+├── observaciones.py motivos de rechazo con sus textos
+├── respuesta_proveedor.py respuesta con plantilla al remitente (modo prueba/real, protecciones)
+├── plantillas.py renderizador de las plantillas HTML
└── pipeline_utils.py log, ruta_carpeta
```
@@ -54,29 +57,34 @@ flowchart TD
F -- Sí --> G[4. _descomprimir ZIPs]
G --> H[5. filtrar_y_limpiar]
H --> I{Hay PDF/XML?}
- I -- No --> C
+ I -- No --> RC[Rechazo del correo
responder al remitente]
I -- Sí --> J[6. procesar_pdfs]:::highlight
J --> K[7. procesar_xmls]:::highlight
K --> K2[8. completar_guias]
K2 --> K3[9. validar_guias]
K3 --> L{Hay documentos?}
- L -- No --> C
+ L -- No --> RC
L -- Sí --> M[10. guardar_resultado]
M --> N[11. Filtrar facturas tipo 01]
N --> O{Hay facturas?}
- O -- No --> C
+ O -- No --> RC
O -- Sí --> P[Bucle: por cada factura]
P --> Q[12. validate_prices]:::highlight
Q --> Q2[12.5 obtener_valor_referencial_sap]
Q2 --> R[13. build_sap_draft]:::highlight
R --> S[14. guardar_draft_json]
- S --> T{Todas coinciden?}
- T -- Sí --> U[15. sapb1_manager.create_draft]
- T -- No --> V[⏭️ Draft NO creado]
- U --> W[16. outlook_utilities.send_email]
+ S --> T{¿Error de SAP/HANA?}
+ T -- Sí --> X[estado error]
+ T -- No --> T2{Todas coinciden?}
+ T2 -- Sí --> U[15. sapb1_manager.create_draft]
+ T2 -- No --> V[estado rechazado + observaciones]
+ U --> U2[estado aceptado]
+ U2 --> W[16. Correo técnico al equipo
+ respuesta con plantilla al remitente]
V --> W
+ X --> W2[16. Correo técnico al equipo
NO se responde al proveedor]
W --> P
+ W2 --> P
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
```
@@ -174,5 +182,23 @@ run_pipeline
│ ├── guardar_draft_json
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
│ ├── build_notification_body
- │ └── outlook_utilities.send_email
+ │ ├── respuesta_proveedor.enviar_a_equipo (correo técnico, solo equipo)
+ │ └── respuesta_proveedor.responder (plantilla aceptado/rechazado, ver respuesta_proveedor.md)
```
+
+---
+
+## Qué se envía y a quién (paso 16)
+
+| Resultado de la factura | Correo técnico (tablas de SAP, JSON adjuntos) | Respuesta con plantilla |
+|-------------------------|-----------------------------------------------|-------------------------|
+| `aceptado` (draft creado) | Equipo | Aceptado, al remitente |
+| `rechazado` (precio, guía…) | Equipo | Rechazado con observaciones, al remitente |
+| `error` (SAP/HANA, fallo inesperado) | Equipo | **Ninguna**: es una falla nuestra, no del proveedor |
+| Correo sin comprobante válido (sin PDF/XML, sin XML, sin factura) | — | Rechazado sin comprobante, al remitente |
+| Correo sin adjuntos | — | Ninguna |
+
+"Al remitente" depende de `NOTIFICACION_MODO` (en modo `prueba` va al equipo) y de las protecciones. Todo en [respuesta_proveedor.md](respuesta_proveedor.md).
+
+Un fallo inesperado al procesar una factura ya no interrumpe el ciclo: se registra como `error`, se avisa al equipo y se sigue con la siguiente.
+
diff --git a/docs/respuesta_proveedor.md b/docs/respuesta_proveedor.md
new file mode 100644
index 0000000..714a3d2
--- /dev/null
+++ b/docs/respuesta_proveedor.md
@@ -0,0 +1,92 @@
+# respuesta_proveedor.py — Respuesta al proveedor
+
+Cuando alguien envía un correo con el XML y el PDF de una factura, el sistema valida y **responde a esa misma dirección** con la plantilla de **aceptado** o de **rechazado** (`plantilla/plantilla-aceptado.html` y `plantilla-rechazado.html`). Aparte, el equipo recibe un correo técnico (tablas de SAP, JSON adjuntos).
+
+Piezas: `respuesta_proveedor.py` (contexto, protecciones, envío), `observaciones.py` (motivos de rechazo y sus textos), `plantillas.py` (renderizador) y `seguimiento.py` (código de seguimiento y registro).
+
+## Qué se responde
+
+| Situación | Respuesta al remitente |
+|-----------|------------------------|
+| Draft creado en SAP | **Aceptado** |
+| Precios que no coinciden, guía inexistente, línea sin guía, cantidad cero | **Rechazado** con una observación por motivo |
+| Correo con adjuntos pero sin PDF ni XML válido, o con PDF y sin XML, o sin ninguna factura (tipo 01) | **Rechazado** sin comprobante: no muestra proveedor, número ni tabla de guías |
+| Error de SAP o de HANA, o un fallo inesperado | **Nada**: es una falla nuestra. Solo se avisa al equipo |
+| Correo sin adjuntos | **Nada** |
+
+Una respuesta por comprobante: un correo con 3 facturas genera 3 respuestas. La aceptación significa "draft creado en SAP". La diferencia de valor referencial XML vs SAP es solo informativa y no rechaza.
+
+> **Guía "no encontrada" y caídas de HANA.** `get_delivery_notes` devuelve lista vacía tanto si la guía no existe como si SAP/HANA fallan. Antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, se trata como error interno y no se le dice nada al proveedor.
+
+## Modo prueba y modo real
+
+`NOTIFICACION_MODO` en el `.env`:
+
+| Modo | A quién va la respuesta | Asunto |
+|------|-------------------------|--------|
+| `prueba` (**por defecto**) | Al equipo (`NOTIFICACION_EQUIPO`), **nunca** al remitente | `[PRUEBA → remitente] Aceptado · Factura F002-486 · ABR-…` |
+| `real` | Al remitente, con **copia oculta** al equipo | `Aceptado · Factura F002-486 · ABR-…` |
+
+Cualquier otro valor se trata como `prueba`. Al arrancar, `config_validator` deja en el log una advertencia con el modo activo. Pasar a `real` es una decisión explícita: no ocurre solo.
+
+## Protecciones: cuándo NO se responde
+
+El sistema contesta solo y a terceros, así que no responde a:
+
+| Caso | Motivo registrado |
+|------|-------------------|
+| Remitente vacío o desconocido | remitente desconocido |
+| Dirección sin dominio válido (por ejemplo `test@local`) | dirección no válida |
+| El propio buzón del sistema | el remitente es el propio buzón |
+| `no-reply`, `noreply`, `do-not-reply`, `no-responder`, `mailer-daemon`, `postmaster`, `bounce` | dirección automática |
+| Mensajes automáticos (respuestas automáticas, rebotes, listas) | mensaje automático |
+| En correos **sin comprobante**: asunto con `Re:`/`RV:` o que cita un código `ABR-…` | parece una respuesta a una notificación nuestra |
+| En correos sin comprobante: más de 3 respuestas al mismo remitente en 24 h | límite de respuestas |
+
+Cada omisión queda en `comprobantes` como `notificacion_estado = omitida`, con el motivo.
+
+## Qué ve el proveedor
+
+- **Importe por guía:** el importe de la línea aparece una vez, en su primera guía; las demás dicen "incluida en la línea". Todas las guías de una línea llevan el mismo resultado, porque la validación es por línea.
+- **Rechazo por precio:** se muestran "Importe en su factura" e "Importe esperado" (el de SAP).
+- **Pago estimado:** solo si hay condición de pago (fecha de vencimiento posterior a la de contabilización).
+- **Sin adjuntos internos:** la respuesta no lleva archivos.
+
+## Observaciones (`observaciones.py`)
+
+Los textos son un **borrador para revisar**; se cambian en ese archivo sin tocar la lógica.
+
+| Código | Cuándo |
+|--------|--------|
+| `precio_no_coincide` | El total de la línea del XML no coincide con la suma de SAP |
+| `guia_no_encontrada` | SAP no devuelve datos para la guía |
+| `linea_sin_guia` | Una línea no indica su guía de remisión |
+| `cantidad_cero` | Una línea tiene cantidad cero |
+| `sin_pdf_xml` | El correo trae adjuntos, pero ninguno es PDF ni XML |
+| `falta_xml` | Hay PDF y ningún XML |
+| `sin_factura` | El XML no contiene una factura electrónica (tipo 01) |
+
+## Configuración (`.env`)
+
+| Variable | Para qué | Por defecto |
+|----------|----------|-------------|
+| `NOTIFICACION_MODO` | `prueba` o `real` | `prueba` |
+| `NOTIFICACION_EQUIPO` | Direcciones internas (coma o `;`): correo técnico, y respuestas en modo prueba | `jlopez@aberio.com.pe`, `gcalle@pucp.pe` |
+| `NOTIFICACION_CONTACTO` | Dirección del botón "Escribir a Aberio" | el buzón `EMAIL` |
+| `LOGO_URL` | URL pública (https) del logo | vacío: sin logo |
+
+El buzón `EMAIL` es también el `envio_correo` al que se pide reenviar el comprobante corregido.
+
+## Cómo probar
+
+1. **Vista previa y envío de ejemplos:** `python tools/previsualizar_plantillas.py` (ver su ayuda). Los ejemplos están en `plantilla/ejemplos.json`.
+2. **Piloto en modo prueba:** con `NOTIFICACION_MODO=prueba` el flujo completo corre y las respuestas llegan solo al equipo, indicando en el asunto a quién habrían ido. El correo técnico también.
+3. **Pasar a real:** cuando el equipo valide los correos, cambiar a `NOTIFICACION_MODO=real`. Revisar antes que `LOGO_URL` tenga valor.
+
+## Limitaciones conocidas
+
+- **Un fallo de envío no se reintenta.** Queda `fallida` en `comprobantes`, pero el correo ya está marcado como procesado.
+- **Si SAP está caído, el correo tampoco se reintenta.** Se avisa al equipo y el correo queda procesado (con estado `error`).
+- **Un comprobante ya aceptado que se reenvía** genera otro draft y otra respuesta "Aceptado"; hoy solo se advierte en el log.
+- La respuesta va a la dirección `from` del mensaje. Si alguien reenvía la factura de un proveedor, la respuesta le llega a quien reenvió.
+- No se prueba contra el logo real ni contra Outlook de escritorio: hay que revisar los correos a mano.
diff --git a/docs/seguimiento.md b/docs/seguimiento.md
index cb80af9..289cbf4 100644
--- a/docs/seguimiento.md
+++ b/docs/seguimiento.md
@@ -22,6 +22,7 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `doc_entry` | DocEntry del draft creado en SAP |
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida` u `omitida`, y su motivo |
+| `notificacion_destinatario` | Direcciones a las que se envió **de verdad** (en modo prueba no son las del remitente) |
| `creado_en`, `actualizado_en` | Marcas de tiempo |
## Funciones
@@ -32,7 +33,8 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `registrar_correo(email_id)` | Igual, para un correo sin comprobante. Idempotente por correo. |
| `obtener(codigo)` | Devuelve el registro o `None`. |
| `actualizar_resultado(codigo, estado, observaciones=None, doc_entry=None)` | Guarda el resultado. `ValueError` si el estado no es válido, `LookupError` si no existe el código. |
-| `registrar_notificacion(codigo, estado, error=None)` | Guarda el estado de la notificación. Mismas excepciones. |
+| `registrar_notificacion(codigo, estado, error=None, destinatario=None)` | Guarda el estado de la notificación y a quién se envió. Mismas excepciones. |
+| `contar_respuestas_correo(remitente, horas=24)` | Respuestas `enviada` a ese remitente en correos sin comprobante: el freno contra bucles de `respuesta_proveedor`. |
## Uso en el pipeline
@@ -44,12 +46,17 @@ En `pipeline._procesar_un_correo`, cada factura se registra al empezar (`_regist
| Precios no coinciden con SAP | `rechazado` | `[{"codigo": "precios_no_coinciden"}]` |
| Error al crear el draft | `error` | `[{"codigo": "error_sap", "detalle": "..."}]` |
+`init_db` agrega la columna `notificacion_destinatario` a una tabla creada antes sin ella, sin tocar los datos.
+
El seguimiento **nunca impide procesar la factura**: si falla la base de datos, se registra un `WARNING` y el flujo continúa. Si el comprobante ya había llegado antes, se registra un `WARNING` con las veces recibidas y el estado anterior, pero **no se bloquea** la creación de otro draft.
-## Estado actual y pendiente
+## Observaciones guardadas
-- Los `observaciones` guardados hoy son mínimos. Se reemplazarán por observaciones estructuradas (título, filas, cómo corregir) cuando se definan las validaciones de rechazo.
-- `registrar_correo` y `registrar_notificacion` están implementados y probados, pero el pipeline todavía no los llama: los usarán las notificaciones al proveedor.
+- Rechazo de una factura: la lista de observaciones completa (`codigo`, `titulo`, `filas`, `como_corregir`; ver `observaciones.py`).
+- Error: `[{"codigo": "error_interno", "detalle": "..."}]`.
+- Correo sin comprobante: la observación del motivo (`sin_pdf_xml`, `falta_xml`, `sin_factura`).
+
+Las notificaciones las registra `respuesta_proveedor` (`enviada`, `fallida` u `omitida` con su motivo).
## Notas
diff --git a/draft_sap.py b/draft_sap.py
index f0bcb46..70b2fae 100644
--- a/draft_sap.py
+++ b/draft_sap.py
@@ -11,6 +11,35 @@ from pipeline_utils import log
# ============================================================================
+def hay_error_de_consulta(resultados_val: list[dict]) -> str | None:
+ """
+ Devuelve el detalle del primer error de consulta a SAP que dejó ``validate_prices``
+ (una guía cuya consulta lanzó excepción), o None si no hubo ninguno. Un error de
+ consulta es una falla nuestra: no debe confundirse con una guía inexistente.
+ """
+ for resultado in resultados_val:
+ for item in resultado.get("guias_lt", []):
+ if item.get("error"):
+ return item["error"]
+ return None
+
+
+def hana_disponible() -> bool:
+ """
+ True si se puede abrir una conexión a SAP HANA. ``get_delivery_notes`` devuelve ``[]``
+ tanto si una guía no existe como si HANA está caído; antes de decirle a un proveedor
+ que una guía "no se encontró" conviene descartar que haya sido una caída.
+ """
+ conexion = hana_manager.get_connection()
+ if conexion is None:
+ return False
+ try:
+ conexion.close()
+ except Exception:
+ pass
+ return True
+
+
def calcular_fecha_vencimiento(fecha_base: str, condicion_pago: dict | None) -> str:
"""
Calcula la fecha de vencimiento sumando a ``fecha_base`` los días de
@@ -341,7 +370,7 @@ def validate_prices(factura: dict) -> tuple[bool, list[dict]]:
all_wh.update(wh_set)
except Exception as ex:
log(f"⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}", 2, severity=logging.WARNING)
- guias_lt.append({"guia": guia, "line_total": 0.0, "warehouse_code": "A-CENTRA", "weight1": 0.0, "quantity": 0.0, "price_after_vat_sum": 0.0, "zona": "", "draft_quantity": 0, "draft_price": 0.0})
+ guias_lt.append({"guia": guia, "line_total": 0.0, "warehouse_code": "A-CENTRA", "weight1": 0.0, "quantity": 0.0, "price_after_vat_sum": 0.0, "zona": "", "draft_quantity": 0, "draft_price": 0.0, "error": str(ex)})
all_ok = False
sap_total = sum(item["line_total"] for item in guias_lt)
diff --git a/observaciones.py b/observaciones.py
new file mode 100644
index 0000000..9505a2f
--- /dev/null
+++ b/observaciones.py
@@ -0,0 +1,157 @@
+"""
+Catálogo de observaciones: los motivos por los que se rechaza un comprobante.
+
+Cada observación se muestra al proveedor en la plantilla de rechazo con un
+título, unas filas de detalle y un texto de "cómo corregir". **Los textos de
+este archivo son un borrador** para revisar y ajustar; no hace falta tocar la
+lógica para cambiarlos.
+
+``desde_validacion`` convierte el resultado de ``draft_sap.validate_prices`` en
+observaciones; el resto son los motivos de un correo sin comprobante válido.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Observacion:
+ codigo: str # identificador estable (se guarda en la BD)
+ titulo: str
+ filas: tuple[tuple[str, str], ...] = () # (etiqueta, valor) de detalle
+ como_corregir: str = ""
+
+ def a_dict(self) -> dict:
+ """Forma serializable: se guarda en ``comprobantes.observaciones``."""
+ return {
+ "codigo": self.codigo,
+ "titulo": self.titulo,
+ "filas": [{"etiqueta": e, "valor": v} for e, v in self.filas],
+ "como_corregir": self.como_corregir,
+ }
+
+
+# ============================================================================
+# Formato
+# ============================================================================
+def moneda(valor: float | int | None) -> str:
+ """``1250`` → ``'S/ 1,250.00'``."""
+ return f"S/ {float(valor or 0):,.2f}"
+
+
+def texto_observaciones(cantidad: int) -> str:
+ """``1`` → ``'1 observación'``, ``2`` → ``'2 observaciones'``."""
+ return f"{cantidad} observación" if cantidad == 1 else f"{cantidad} observaciones"
+
+
+def _nombre_guia(guia: dict) -> str:
+ return f"{guia.get('serie', '')}-{guia.get('numero', '')}"
+
+
+# ============================================================================
+# Motivos de rechazo de una factura (validación contra SAP)
+# ============================================================================
+def precio_no_coincide(numero_linea: int, importe_factura: float, importe_esperado: float,
+ guias: list[str], varias_lineas: bool) -> Observacion:
+ filas = []
+ if varias_lineas:
+ filas.append(("Línea", str(numero_linea)))
+ filas += [
+ ("Guías", ", ".join(guias)),
+ ("Importe en su factura", moneda(importe_factura)),
+ ("Importe esperado", moneda(importe_esperado)),
+ ]
+ return Observacion(
+ "precio_no_coincide", "El importe no coincide con nuestro registro", tuple(filas),
+ "Revise el importe acordado para este servicio y emita el comprobante con el valor correcto.",
+ )
+
+
+def guia_no_encontrada(guia: str) -> Observacion:
+ return Observacion(
+ "guia_no_encontrada", f"No encontramos la guía {guia}", (("Guía", guia),),
+ "Verifique que la guía esté registrada y que su serie y número estén bien escritos en la factura.",
+ )
+
+
+def linea_sin_guia(numero_linea: int, descripcion: str) -> Observacion:
+ filas = [("Línea", str(numero_linea))]
+ if descripcion:
+ filas.append(("Descripción", descripcion[:60]))
+ return Observacion(
+ "linea_sin_guia", "Una línea de la factura no indica su guía de remisión", tuple(filas),
+ "Indique la guía de remisión de cada servicio en la factura y envíela nuevamente.",
+ )
+
+
+def cantidad_cero(numero_linea: int) -> Observacion:
+ return Observacion(
+ "cantidad_cero", "Una línea de la factura tiene cantidad cero", (("Línea", str(numero_linea)),),
+ "Corrija la cantidad de la línea y emita el comprobante nuevamente.",
+ )
+
+
+# ============================================================================
+# Motivos de rechazo de un correo sin comprobante válido
+# ============================================================================
+def sin_pdf_xml(archivos_recibidos: int) -> Observacion:
+ return Observacion(
+ "sin_pdf_xml", "No recibimos el XML ni el PDF de su comprobante",
+ (("Archivos recibidos", str(archivos_recibidos)),),
+ "Adjunte el XML y el PDF de la factura electrónica en el mismo correo.",
+ )
+
+
+def falta_xml(pdfs_recibidos: int) -> Observacion:
+ return Observacion(
+ "falta_xml", "Falta el archivo XML de su comprobante", (("PDF recibidos", str(pdfs_recibidos)),),
+ "Adjunte también el XML de la factura electrónica: el PDF por sí solo no es suficiente.",
+ )
+
+
+def sin_factura(documentos_recibidos: list[str]) -> Observacion:
+ return Observacion(
+ "sin_factura", "No encontramos una factura electrónica en su correo",
+ (("Documentos recibidos", ", ".join(documentos_recibidos) or "ninguno reconocible"),),
+ "Envíe el XML y el PDF de la factura electrónica. Los demás documentos no se registran por este medio.",
+ )
+
+
+# ============================================================================
+# Resultado de la validación de precios → observaciones
+# ============================================================================
+def _sin_datos_sap(item: dict) -> bool:
+ """La guía no devolvió ningún dato de SAP (y no fue por un error de consulta)."""
+ return item.get("line_total") == 0 and item.get("quantity") == 0 and not item.get("error")
+
+
+def desde_validacion(factura: dict, resultados_val: list[dict]) -> list[Observacion]:
+ """
+ Observaciones de una factura a partir de los resultados de ``validate_prices``
+ (uno por línea). Una línea que no coincide da una observación por cada guía
+ que no se encontró y, si hay datos de SAP para alguna otra, una de importe.
+ """
+ lineas = factura.get("lineas", [])
+ varias_lineas = len(lineas) > 1
+ observaciones: list[Observacion] = []
+
+ for i, resultado in enumerate(resultados_val):
+ if resultado.get("ok"):
+ continue
+ numero = i + 1
+ linea = lineas[i] if i < len(lineas) else {}
+ etiqueta = resultado.get("label", "")
+
+ if etiqueta == "Cantidad cero, sin validar":
+ observaciones.append(cantidad_cero(numero))
+ elif etiqueta == "Sin guía de referencia":
+ observaciones.append(linea_sin_guia(numero, linea.get("descripcion", "")))
+ else:
+ guias_lt = resultado.get("guias_lt", [])
+ no_encontradas = [it for it in guias_lt if _sin_datos_sap(it)]
+ observaciones += [guia_no_encontrada(_nombre_guia(it["guia"])) for it in no_encontradas]
+ if len(no_encontradas) < len(guias_lt):
+ observaciones.append(precio_no_coincide(
+ numero, float(linea.get("total_linea") or 0), float(resultado.get("sap_price") or 0),
+ [_nombre_guia(it["guia"]) for it in guias_lt], varias_lineas,
+ ))
+ return observaciones
diff --git a/outlook_utilities.py b/outlook_utilities.py
index 8668588..ec5ed43 100644
--- a/outlook_utilities.py
+++ b/outlook_utilities.py
@@ -52,11 +52,30 @@ def get_access_token() -> str:
correlation_id = result.get("correlation_id")
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})")
+# ============================================================================
+def es_mensaje_automatico(headers: list[dict] | None) -> bool:
+ """
+ Indica si un mensaje fue generado automáticamente (respuesta automática,
+ rebote, lista de distribución…) a partir de ``internetMessageHeaders``.
+
+ No se debe responder a estos mensajes: se generarían bucles de correos.
+ """
+ h = {(x.get("name") or "").lower(): (x.get("value") or "").strip().lower() for x in headers or []}
+ if h.get("auto-submitted", "no") not in ("", "no"):
+ return True
+ if "x-auto-response-suppress" in h or "x-autoreply" in h or "x-autorespond" in h:
+ return True
+ if h.get("precedence") in ("bulk", "junk", "list", "auto_reply"):
+ return True
+ return "report-type=" in h.get("content-type", "") # multipart/report: acuse o rebote de entrega
+
+
# ============================================================================
def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
"""
Obtiene los correos de una cuenta específica en un rango de fechas.
- Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach.
+ Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject, has_attach
+ y es_automatico (True si es una respuesta automática o un rebote).
"""
headers = {
"Authorization": f"Bearer {token}",
@@ -73,7 +92,7 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
params = {
"$filter": filter_query,
- "$select": "id,receivedDateTime,hasAttachments,subject,from",
+ "$select": "id,receivedDateTime,hasAttachments,subject,from,internetMessageHeaders",
"$top": 50
}
@@ -107,7 +126,8 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
"sender": sender,
"subject": msg.get("subject", ""),
- "has_attach": msg.get("hasAttachments")
+ "has_attach": msg.get("hasAttachments"),
+ "es_automatico": es_mensaje_automatico(msg.get("internetMessageHeaders")),
})
url = data.get("@odata.nextLink")
@@ -153,7 +173,7 @@ def download_attachments(user_email: str, message_id: str, token: str, download_
return downloaded_files
# ============================================================================
-def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None) -> bool:
+def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None, bcc_recipients: list[str] | None = None) -> bool:
"""
Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF.
@@ -164,6 +184,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
:param html_body: Contenido del correo en formato HTML.
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
:param cc_recipients: Lista de correos electrónicos en copia (CC).
+ :param bcc_recipients: Lista de correos electrónicos en copia oculta (BCC): no los ve el resto de destinatarios.
:return: True si se envió correctamente, False en caso contrario.
"""
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
@@ -192,6 +213,9 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
}
})
+ # Construir lista de destinatarios en copia oculta (BCC) para Graph API
+ bcc_payload = [{"emailAddress": {"address": email}} for email in (bcc_recipients or [])]
+
# Construir lista de adjuntos
attachments_payload = []
if attachment_paths:
@@ -229,6 +253,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
},
"toRecipients": recipients_payload,
"ccRecipients": cc_payload,
+ "bccRecipients": bcc_payload,
"attachments": attachments_payload
},
"saveToSentItems": "true"
diff --git a/pipeline.py b/pipeline.py
index e6865bd..648ef05 100644
--- a/pipeline.py
+++ b/pipeline.py
@@ -20,6 +20,8 @@ import documentos as docs
import persistencia
import draft_sap
import notificaciones
+import observaciones
+import respuesta_proveedor
import sapb1_manager
import seguimiento
from pipeline_utils import log
@@ -61,6 +63,33 @@ def _registrar_resultado(registro: dict | None, estado: str, **kwargs) -> None:
log(f"⚠️ No se pudo guardar el resultado en seguimiento: {ex}", severity=logging.WARNING)
+def _registrar_correo(email: dict) -> dict | None:
+ """Como ``_registrar_comprobante``, para un correo sin comprobante identificable."""
+ try:
+ registro = seguimiento.registrar_correo(email["id"])
+ except Exception as ex:
+ log(f"⚠️ No se pudo registrar el correo en seguimiento: {ex}", severity=logging.WARNING)
+ return None
+ log(f"🔖 Código de seguimiento: {registro['codigo_seguimiento']}")
+ return registro
+
+
+def _rechazar_correo(email: dict, token: str, email_addr: str, lista_obs: list) -> None:
+ """
+ Rechaza un correo que no trae ningún comprobante válido: registra el motivo y
+ responde al remitente con la plantilla de rechazo (si las protecciones lo permiten).
+ """
+ log(f"❌ Correo rechazado: {lista_obs[0].titulo}", severity=logging.WARNING)
+ registro = _registrar_correo(email)
+ _registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
+ if not registro or not token:
+ return
+ respuesta_proveedor.responder(
+ token, email_addr, email, "rechazado",
+ respuesta_proveedor.contexto_rechazo_correo(email, registro, lista_obs), registro, sin_comprobante=True,
+ )
+
+
# ============================================================================
# PASO 1: Autenticación Microsoft Graph
# ============================================================================
@@ -146,14 +175,16 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
# 3. Descargar adjuntos
lista_adjuntos = adjuntos.descargar_adjuntos(email, email_addr, token, download_folder)
if not lista_adjuntos:
- log("⏭️ Correo sin adjuntos")
+ log("⏭️ Correo sin adjuntos") # no se responde: no hubo intento de enviar un comprobante
return
+ n_recibidos = len(lista_adjuntos)
# 4. Descomprimir ZIPs
email_utilities.descomprimir_adjuntos([email], download_folder)
# 5. Filtrar PDF/XML y limpiar si no hay válidos
if not adjuntos.filtrar_y_limpiar(email, download_folder):
+ _rechazar_correo(email, token, email_addr, [observaciones.sin_pdf_xml(n_recibidos)])
return
# 6. Procesar PDFs
@@ -163,6 +194,11 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
documentos = docs.procesar_xmls(email, download_folder, pdf_cache)
if not documentos:
log("⏭️ No se extrajeron documentos XML")
+ if any(a.lower().endswith(".xml") for a in email["adjuntos"]):
+ motivo = observaciones.sin_factura([])
+ else:
+ motivo = observaciones.falta_xml(sum(a.lower().endswith(".pdf") for a in email["adjuntos"]))
+ _rechazar_correo(email, token, email_addr, [motivo])
return
# 8. Completar guías faltantes desde PDF
@@ -181,6 +217,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
facturas = [d for d in documentos if d.get("tipo") == "01"]
if not facturas:
log("⏭️ No hay facturas (tipo 01) para SAP")
+ _rechazar_correo(email, token, email_addr,
+ [observaciones.sin_factura(sorted({d.get("tipo_descripcion", "?") for d in documentos}))])
return
for factura in facturas:
@@ -190,55 +228,89 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
log(f"📄 Factura: {serie}-{numero}")
registro = _registrar_comprobante(email, factura)
- # 12. Validar precios (consulta SAP DNs, NO requiere draft)
- todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
- validation_table = notificaciones.build_validation_html(resultados_val)
-
- # 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
- guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
- valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
-
- # 13. Construir draft (usa LineTotal de los DNs, no del XML)
- draft = draft_sap.build_sap_draft(factura, resultados_val)
-
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
- # 14. Guardar draft JSON (siempre, para depuración)
- persistencia.guardar_draft_json(draft, serie, numero, output_folder)
+ estado = None # aceptado | rechazado | error
+ detalle_error = None
+ lista_obs: list = []
+ resultados_val: list = []
+ draft: dict = {}
+ validation_table = valor_referencial_table = ""
- # 15. Crear draft en SAP o generar HTML
- if todas_coinciden:
+ try:
+ # 12. Validar precios (consulta SAP DNs, NO requiere draft)
+ todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
+ validation_table = notificaciones.build_validation_html(resultados_val)
+
+ # 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
+ guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
+ valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
+
+ # 13. Construir draft (usa LineTotal de los DNs, no del XML)
+ draft = draft_sap.build_sap_draft(factura, resultados_val)
+
+ # 14. Guardar draft JSON (siempre, para depuración)
+ persistencia.guardar_draft_json(draft, serie, numero, output_folder)
+
+ detalle_error = draft_sap.hay_error_de_consulta(resultados_val)
+ if not todas_coinciden:
+ lista_obs = observaciones.desde_validacion(factura, resultados_val)
+ # "guía no encontrada" también es lo que se ve si HANA está caído: no culpar al proveedor
+ if (not detalle_error and any(o.codigo == "guia_no_encontrada" for o in lista_obs)
+ and not draft_sap.hana_disponible()):
+ detalle_error = "SAP HANA no responde: no se puede confirmar que las guías no existan"
+ except Exception as ex:
+ detalle_error = f"{type(ex).__name__}: {ex}"
+
+ # 15. Crear draft en SAP o generar el resultado
+ if detalle_error:
+ estado = "error"
+ log(f"❌ Error validando {serie}-{numero}: {detalle_error}", severity=logging.ERROR)
+ elif todas_coinciden:
try:
sap_result = sapb1_manager.create_draft(draft)
doc_entry = sap_result.get("DocEntry")
+ estado = "aceptado"
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
except Exception as ex:
+ estado, detalle_error = "error", str(ex)
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
- _registrar_resultado(registro, "error", observaciones=[{"codigo": "error_sap", "detalle": str(ex)}])
- html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
else:
+ estado = "rechazado"
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
- _registrar_resultado(registro, "rechazado", observaciones=[{"codigo": "precios_no_coinciden"}])
+ _registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
- # 16. Enviar notificación al remitente original, con copia al equipo
- if token:
- destinatario = email.get("sender")
- if destinatario and destinatario != "Desconocido":
- outlook_utilities.send_email(
- token, email_addr,
- [destinatario],
- f"Draft SAP B1 - {serie}-{numero}",
- html_body, adjuntos_email,
- cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
- )
- else:
- log("⚠️ No se pudo determinar el remitente, notificación no enviada", severity=logging.WARNING)
+ if estado == "error":
+ _registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
+ html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
+
+ # 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
+ if not token:
+ continue
+ respuesta_proveedor.enviar_a_equipo(
+ token, email_addr, f"Draft SAP B1 - {serie}-{numero}",
+ respuesta_proveedor.encabezado_tecnico(email, registro) + html_body, adjuntos_email,
+ )
+ if registro is None:
+ log("⚠️ Sin código de seguimiento: no se responde al proveedor", severity=logging.WARNING)
+ elif estado == "aceptado":
+ respuesta_proveedor.responder(
+ token, email_addr, email, "aceptado",
+ respuesta_proveedor.contexto_aceptado(email, registro, factura, resultados_val, draft), registro,
+ )
+ elif estado == "rechazado":
+ respuesta_proveedor.responder(
+ token, email_addr, email, "rechazado",
+ respuesta_proveedor.contexto_rechazo_factura(email, registro, factura, resultados_val, lista_obs), registro,
+ )
+ else:
+ respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
# ============================================================================
diff --git a/respuesta_proveedor.py b/respuesta_proveedor.py
new file mode 100644
index 0000000..5f5efc6
--- /dev/null
+++ b/respuesta_proveedor.py
@@ -0,0 +1,285 @@
+"""
+Respuesta al proveedor: arma y envía el correo de aceptación o rechazo (plantillas
+``plantilla/plantilla-aceptado.html`` y ``plantilla-rechazado.html``) a quien envió
+el correo con el XML y el PDF.
+
+Dos modos, según ``NOTIFICACION_MODO`` en el .env:
+
+ prueba (por defecto) la respuesta NO sale al proveedor: va al equipo
+ (``NOTIFICACION_EQUIPO``) con el asunto "[PRUEBA → remitente]".
+ real la respuesta va al remitente, con copia oculta al equipo.
+
+Como el sistema contesta solo y a terceros, **no responde** a: remitentes
+desconocidos o sin dominio válido, el propio buzón, direcciones ``no-reply`` y
+afines, mensajes automáticos (respuestas automáticas, rebotes) y, en correos sin
+comprobante, a respuestas (``Re:``) o mensajes que citan un código nuestro. Además
+limita a ``MAX_RESPUESTAS_CORREO`` por remitente cada 24 h las respuestas a correos
+sin comprobante.
+"""
+
+import logging
+import os
+import re
+from datetime import datetime
+
+from config_validator import MODOS_NOTIFICACION as MODOS
+import observaciones as obs
+import outlook_utilities
+import plantillas
+import seguimiento
+from pipeline_utils import log
+
+EQUIPO_POR_DEFECTO = ["jlopez@aberio.com.pe", "gcalle@pucp.pe"]
+MAX_RESPUESTAS_CORREO = 3
+
+_DIRECCION = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
+_AUTOMATICA = re.compile(r"no[-_.]?reply|do[-_.]?not[-_.]?reply|no[-_.]?responder|mailer-daemon|postmaster|bounce", re.I)
+_CODIGO = re.compile(r"ABR-\d{6}-\d{5}")
+_RESPUESTA = re.compile(r"^\s*(re|rv)\s*:", re.I)
+
+
+# ============================================================================
+# Configuración (.env)
+# ============================================================================
+def modo() -> str:
+ """``prueba`` o ``real``. Cualquier otro valor (o ninguno) es ``prueba``."""
+ valor = os.getenv("NOTIFICACION_MODO", "prueba").strip().lower()
+ return valor if valor in MODOS else "prueba"
+
+
+def equipo() -> list[str]:
+ """Direcciones internas (``NOTIFICACION_EQUIPO``, separadas por coma o punto y coma)."""
+ lista = [d.strip() for d in re.split(r"[;,]", os.getenv("NOTIFICACION_EQUIPO", "")) if d.strip()]
+ return lista or list(EQUIPO_POR_DEFECTO)
+
+
+def contacto_correo() -> str:
+ return os.getenv("NOTIFICACION_CONTACTO", "").strip() or os.getenv("EMAIL", "").strip()
+
+
+def envio_correo() -> str:
+ """Buzón al que el proveedor debe reenviar el comprobante corregido."""
+ return os.getenv("EMAIL", "").strip()
+
+
+def logo_url() -> str:
+ return os.getenv("LOGO_URL", "").strip()
+
+
+# ============================================================================
+# Protecciones
+# ============================================================================
+def motivo_omision(email: dict, buzon: str, sin_comprobante: bool = False) -> str | None:
+ """
+ Motivo por el que NO se debe responder a este correo, o None si se puede.
+
+ Args:
+ email: Dict del correo (``sender``, ``subject``, ``es_automatico``).
+ buzon: Dirección del buzón del sistema (``EMAIL``).
+ sin_comprobante: True si la respuesta es un rechazo de un correo sin comprobante válido.
+ """
+ remitente = (email.get("sender") or "").strip()
+ if not remitente or remitente == "Desconocido":
+ return "remitente desconocido"
+ if not _DIRECCION.match(remitente):
+ return f"dirección no válida ({remitente})"
+ if remitente.lower() == buzon.strip().lower():
+ return "el remitente es el propio buzón"
+ if _AUTOMATICA.search(remitente.split("@")[0]):
+ return "dirección automática (no-reply)"
+ if email.get("es_automatico"):
+ return "mensaje automático (respuesta automática o rebote)"
+ if sin_comprobante:
+ asunto = email.get("subject") or ""
+ if _RESPUESTA.match(asunto) or _CODIGO.search(asunto):
+ return "parece una respuesta a una notificación nuestra"
+ return None
+
+
+def _destinatarios(remitente: str) -> tuple[list[str], list[str], str]:
+ """(para, copia oculta, prefijo del asunto) según el modo."""
+ if modo() == "real":
+ return [remitente], equipo(), ""
+ return equipo(), [], f"[PRUEBA → {remitente}] "
+
+
+# ============================================================================
+# Contexto de las plantillas
+# ============================================================================
+def _fecha_hora(email: dict) -> tuple[str, str]:
+ try:
+ dt = datetime.strptime(email["fecha"], "%Y-%m-%d %H:%M:%S")
+ except (KeyError, ValueError, TypeError):
+ dt = datetime.now()
+ return f"{dt:%d/%m/%Y}", f"{dt:%H:%M}"
+
+
+def _comun(email: dict, registro: dict) -> dict:
+ fecha, hora = _fecha_hora(email)
+ return {
+ "logo_url": logo_url(),
+ "recepcion_fecha": fecha,
+ "recepcion_hora": hora,
+ "codigo_seguimiento": registro["codigo_seguimiento"],
+ "contacto_correo": contacto_correo(),
+ }
+
+
+def _comprobante(factura: dict) -> dict:
+ numero = f"{factura.get('serie', '')}-{factura.get('numero', '')}"
+ tipo = "Factura electrónica" if factura.get("tipo") == "01" else factura.get("tipo_descripcion", "Comprobante")
+ return {
+ "comprobante_tipo_corto": "factura",
+ "comprobante_tipo": tipo,
+ "comprobante_numero": numero,
+ "proveedor_razon_social": factura.get("razon_social_emisor") or "—",
+ "proveedor_ruc": factura.get("ruc_emisor") or None,
+ }
+
+
+def guias_para_plantilla(factura: dict, resultados_val: list[dict]) -> list[dict]:
+ """
+ Filas de la tabla "Guías revisadas". La validación es por línea, así que todas las
+ guías de una línea llevan el mismo resultado; el importe de la línea se muestra una
+ vez (en su primera guía) y las demás dicen que están incluidas en la línea.
+ """
+ filas = []
+ for i, linea in enumerate(factura.get("lineas", [])):
+ resultado = resultados_val[i] if i < len(resultados_val) else {}
+ guias = [it["guia"] for it in resultado.get("guias_lt", [])] or linea.get("guias_remision") or []
+ for j, guia in enumerate(guias):
+ filas.append({
+ "guia": f"{guia.get('serie', '')}-{guia.get('numero', '')}",
+ "importe_factura": obs.moneda(linea.get("total_linea")) if j == 0 else "incluida en la línea",
+ "conforme": bool(resultado.get("ok")),
+ })
+ return filas
+
+
+def _fecha_estimada_pago(draft: dict) -> str | None:
+ """Fecha de vencimiento del draft, solo si es posterior a la de contabilización (hay condición de pago)."""
+ try:
+ vencimiento = datetime.strptime(draft["DocDueDate"], "%Y-%m-%d")
+ contabilizacion = datetime.strptime(draft["DocDate"], "%Y-%m-%d")
+ except (KeyError, ValueError, TypeError):
+ return None
+ return f"{vencimiento:%d/%m/%Y}" if vencimiento > contabilizacion else None
+
+
+def contexto_aceptado(email: dict, registro: dict, factura: dict, resultados_val: list[dict], draft: dict) -> dict:
+ comprobante = _comprobante(factura)
+ numero = comprobante["comprobante_numero"]
+ return {
+ **_comun(email, registro), **comprobante,
+ "asunto": f"Aceptado · Factura {numero} · {registro['codigo_seguimiento']}",
+ "preheader": f"Su factura {numero} pasó todas las validaciones. No necesita hacer nada más.",
+ "fecha_estimada_pago": _fecha_estimada_pago(draft),
+ "guias": guias_para_plantilla(factura, resultados_val),
+ }
+
+
+def _bloque_observaciones(observaciones: list[obs.Observacion]) -> dict:
+ texto = obs.texto_observaciones(len(observaciones))
+ return {
+ "observaciones_texto": texto,
+ "preheader": f"Tiene {texto}. Corríjalas y envíenos el comprobante nuevamente.",
+ "observaciones": [
+ {"numero": n, "titulo": o.titulo, "como_corregir": o.como_corregir,
+ "filas": [{"etiqueta": e, "valor": v} for e, v in o.filas]}
+ for n, o in enumerate(observaciones, start=1)
+ ],
+ "envio_correo": envio_correo(),
+ }
+
+
+def contexto_rechazo_factura(email: dict, registro: dict, factura: dict, resultados_val: list[dict],
+ observaciones: list[obs.Observacion]) -> dict:
+ comprobante = _comprobante(factura)
+ guias = guias_para_plantilla(factura, resultados_val)
+ return {
+ **_comun(email, registro), **comprobante, **_bloque_observaciones(observaciones),
+ "asunto": f"Rechazado · Factura {comprobante['comprobante_numero']} · {registro['codigo_seguimiento']}",
+ "guias": guias, "tiene_guias": bool(guias),
+ }
+
+
+def contexto_rechazo_correo(email: dict, registro: dict, observaciones: list[obs.Observacion]) -> dict:
+ """Rechazo de un correo sin comprobante identificable: no hay proveedor, número ni guías que mostrar."""
+ return {
+ **_comun(email, registro), **_bloque_observaciones(observaciones),
+ "asunto": f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}",
+ "comprobante_tipo_corto": "comprobante", "comprobante_tipo": "", "comprobante_numero": None,
+ "proveedor_razon_social": email.get("sender") or "—", "proveedor_ruc": None,
+ "guias": [], "tiene_guias": False,
+ }
+
+
+# ============================================================================
+# Envío
+# ============================================================================
+def _anotar(codigo: str, estado: str, error: str | None = None, destinatario: str | None = None) -> None:
+ try:
+ seguimiento.registrar_notificacion(codigo, estado, error=error, destinatario=destinatario)
+ except Exception as ex:
+ log(f"⚠️ No se pudo registrar la notificación en seguimiento: {ex}", severity=logging.WARNING)
+
+
+def responder(token: str, buzon: str, email: dict, plantilla: str, contexto: dict, registro: dict,
+ sin_comprobante: bool = False) -> str:
+ """
+ Responde al remitente con la plantilla ``plantilla`` ("aceptado" o "rechazado").
+
+ Returns:
+ ``"enviada"``, ``"fallida"`` u ``"omitida"``; también queda anotado en ``comprobantes``.
+ """
+ codigo = registro["codigo_seguimiento"]
+ remitente = email.get("sender") or ""
+
+ motivo = motivo_omision(email, buzon, sin_comprobante)
+ if motivo is None and sin_comprobante and seguimiento.contar_respuestas_correo(remitente) >= MAX_RESPUESTAS_CORREO:
+ motivo = f"ya se respondió {MAX_RESPUESTAS_CORREO} veces a este remitente en 24 h"
+ if motivo:
+ log(f"⏭️ No se responde al proveedor: {motivo}", severity=logging.WARNING)
+ _anotar(codigo, "omitida", motivo)
+ return "omitida"
+
+ para, copia_oculta, prefijo = _destinatarios(remitente)
+ try:
+ html = plantillas.renderizar_plantilla(plantilla, contexto)
+ except plantillas.PlantillaError as ex:
+ log(f"❌ No se pudo armar la respuesta ({codigo}): {ex}", severity=logging.ERROR)
+ _anotar(codigo, "fallida", f"Plantilla: {ex}")
+ return "fallida"
+
+ enviado = outlook_utilities.send_email(
+ token, buzon, para, prefijo + contexto["asunto"], html, None, bcc_recipients=copia_oculta or None,
+ )
+ estado = "enviada" if enviado else "fallida"
+ log(f"{'✉️ ' if enviado else '❌'} Respuesta {plantilla} {estado} → {', '.join(para)} (modo {modo()})",
+ severity=logging.INFO if enviado else logging.ERROR)
+ _anotar(codigo, estado, None if enviado else "Microsoft Graph rechazó el envío (ver log)", ", ".join(para))
+ return estado
+
+
+def omitir_respuesta(registro: dict, motivo: str) -> None:
+ """Deja anotado que a este comprobante no se le respondió, y por qué."""
+ _anotar(registro["codigo_seguimiento"], "omitida", motivo)
+
+
+def encabezado_tecnico(email: dict, registro: dict | None) -> str:
+ """Datos de contexto que se anteponen al correo técnico que recibe el equipo."""
+ codigo = registro["codigo_seguimiento"] if registro else "(sin código: falló el seguimiento)"
+ return (
+ f"
Remitente: {email.get('sender', '')}
"
+ f"Asunto: {email.get('subject', '')}
"
+ f"Código de seguimiento: {codigo}
"
+ f"Modo de notificación: {modo()}
hola
", None, + cc_recipients=["c@x.com"], bcc_recipients=["oculto@x.com"]) + + mensaje = enviado["json"]["message"] + assert ok is True and enviado["url"].endswith("/users/buzon@x.com/sendMail") + assert mensaje["toRecipients"] == [{"emailAddress": {"address": "a@x.com"}}] + assert mensaje["ccRecipients"] == [{"emailAddress": {"address": "c@x.com"}}] + assert mensaje["bccRecipients"] == [{"emailAddress": {"address": "oculto@x.com"}}] + assert mensaje["attachments"] == [] and mensaje["body"]["contentType"] == "HTML" + + +def test_send_email_sin_copia_oculta_envia_la_lista_vacia(monkeypatch): + enviado = {} + monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(**k) or _Respuesta()) + o.send_email("tok", "b@x.com", ["a@x.com"], "A", "") + assert enviado["json"]["message"]["bccRecipients"] == [] + + +def test_send_email_devuelve_false_si_graph_rechaza_o_falla(monkeypatch): + monkeypatch.setattr(o.requests, "post", lambda *a, **k: _Respuesta(400, "mal")) + assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "") is False + + def explota(*a, **k): + raise ConnectionError("sin red") + monkeypatch.setattr(o.requests, "post", explota) + assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "") is False + + +# ============================================================================ +# get_emails +# ============================================================================ +def test_get_emails_pide_los_encabezados_y_marca_los_automaticos(monkeypatch): + pedido = {} + + class Resp: + status_code = 200 + + def json(self): + return {"value": [ + {"id": "1", "receivedDateTime": "2026-10-14T15:35:00Z", "hasAttachments": True, "subject": "Factura", + "from": {"emailAddress": {"address": "a@x.com"}}, "internetMessageHeaders": []}, + {"id": "2", "receivedDateTime": "2026-10-14T16:00:00Z", "hasAttachments": False, "subject": "Auto", + "from": {"emailAddress": {"address": "b@x.com"}}, + "internetMessageHeaders": [{"name": "Auto-Submitted", "value": "auto-replied"}]}, + {"id": "3", "receivedDateTime": "2026-10-14T16:00:00Z", "subject": "Sin encabezados", "from": {}}, + ]} + + monkeypatch.setattr(o.requests, "get", lambda url, **k: pedido.update(k) or Resp()) + + correos = o.get_emails("buzon@x.com", "tok", "2026-10-14", "2026-10-14") + + assert "internetMessageHeaders" in pedido["params"]["$select"] + assert [c["es_automatico"] for c in correos] == [False, True, False] + assert correos[0]["fecha"] == "2026-10-14 10:35:00" # UTC-5 + assert correos[2]["sender"] == "Desconocido" diff --git a/tests/test_pipeline_flujo.py b/tests/test_pipeline_flujo.py index 9992188..183b442 100644 --- a/tests/test_pipeline_flujo.py +++ b/tests/test_pipeline_flujo.py @@ -1,7 +1,11 @@ """ -Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y la -BD simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de -624.38 con la guía T001-128912). +Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y Graph +simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de 624.38 +con la guía T001-128912). + +Cada factura genera dos correos: el **técnico**, solo para el equipo (asunto +"Draft SAP B1 - …"), y la **respuesta al proveedor** con la plantilla de aceptado o +rechazado (asunto "Aceptado · …" / "Rechazado · …"). """ import glob import logging @@ -21,39 +25,60 @@ from conftest import FIXTURES CASO = "001_correo" TOTAL_LINEA = 624.38 +SENDER = "proveedor@empresa.com" +EQUIPO = ["equipo1@aberio.com", "equipo2@aberio.com"] class Entorno: - """Registra lo que el pipeline le pide a los servicios externos.""" + """Registra lo que el pipeline le pide a los servicios externos y permite alterar sus respuestas.""" def __init__(self): self.line_total_sap = TOTAL_LINEA + self.guia_existe = True # False: SAP no devuelve ningún documento para la guía + self.sap_lanza = None # excepción que lanza get_delivery_notes self.error_al_crear = None + self.hana_disponible = True + self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos self.drafts = [] self.correos = [] self.descargas = 0 + @property + def tecnicos(self): + return [c for c in self.correos if c["asunto"].startswith("Draft SAP B1")] + + @property + def respuestas(self): + return [c for c in self.correos if " · " in c["asunto"] and ("Aceptado" in c["asunto"] or "Rechazado" in c["asunto"])] + @pytest.fixture def entorno(tmp_path, monkeypatch): e = Entorno() monkeypatch.setenv("RUTA_RED_FACTURACION", "") + monkeypatch.setenv("NOTIFICACION_EQUIPO", ",".join(EQUIPO)) monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: []) - monkeypatch.setattr(database_utilities, "save_emails", lambda emails: None) def descargar(addr, id_, token, carpeta): e.descargas += 1 - os.makedirs(carpeta, exist_ok=True) # como el download_attachments real + os.makedirs(carpeta, exist_ok=True) # como el download_attachments real nombres = [] - for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")): - if ruta.lower().endswith((".pdf", ".xml")): - shutil.copy(ruta, carpeta) - nombres.append(os.path.basename(ruta)) + if e.archivos is None: + for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")): + if ruta.lower().endswith((".pdf", ".xml")): + shutil.copy(ruta, carpeta) + nombres.append(os.path.basename(ruta)) + else: + for nombre, contenido in e.archivos.items(): + with open(os.path.join(carpeta, nombre), "wb") as f: + f.write(contenido) + nombres.append(nombre) return nombres - def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None): - e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients, "adjuntos": adjuntos}) + def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None): + e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients, + "bcc": bcc_recipients, "adjuntos": adjuntos}) return True def crear_draft(draft): @@ -63,10 +88,18 @@ def entorno(tmp_path, monkeypatch): return {"DocEntry": 777} def delivery_notes(**k): + if e.sap_lanza: + raise e.sap_lanza + if not e.guia_existe: + return [] return [{"Series": "1", "DocNum": "9", "U_IDC_DESTINO": "Z1", "DocumentLines": [ {"ItemCode": "FL10007", "LineTotal": e.line_total_sap, "PriceAfterVAT": 1.3396, "Quantity": 466, "WarehouseCode": "A-CENTRA", "Weight1": 1}]}] + class ConexionHana: + def close(self): + pass + monkeypatch.setattr(outlook_utilities, "download_attachments", descargar) monkeypatch.setattr(outlook_utilities, "send_email", enviar) monkeypatch.setattr(sapb1_manager, "get_zonas", lambda: [{"Code": "Z1", "Name": "ZONA1"}]) @@ -74,62 +107,229 @@ def entorno(tmp_path, monkeypatch): monkeypatch.setattr(sapb1_manager, "obtener_condicion_pago", lambda c: {"NumberOfAdditionalDays": 30}) monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft) monkeypatch.setattr(hana_manager, "obtener_valor_referencial", lambda s, n: 0.0) + monkeypatch.setattr(hana_manager, "get_connection", lambda: ConexionHana() if e.hana_disponible else None) e.descarga = tmp_path / "descarga" e.salida = tmp_path / "salida" return e -def _procesar(e, sender="proveedor@empresa.com"): - email = {"id": "abc", "fecha": "2026-10-14 10:00:00", "subject": "Factura", "sender": sender} - pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(e.descarga), str(e.salida)) +def _procesar(e, sender=SENDER, subject="Factura", id_="abc", es_automatico=False, carpeta=None): + email = {"id": id_, "fecha": "2026-10-14 10:35:00", "subject": subject, "sender": sender, + "has_attach": True, "es_automatico": es_automatico} + pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(carpeta or e.descarga), str(e.salida)) return email -def test_precios_coinciden_crea_draft_y_notifica(entorno): +def _registros(): + conn = sqlite3.connect(database_utilities.DB_NAME) + conn.row_factory = sqlite3.Row + try: + return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")] + finally: + conn.close() + + +# ============================================================================ +# Aceptado +# ============================================================================ +def test_aceptado_crea_draft_y_responde_con_la_plantilla(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + _procesar(entorno) assert len(entorno.drafts) == 1 draft = entorno.drafts[0] - assert draft["CardCode"] == "PL10060028686" - assert draft["NumAtCard"] == "01F002-2450" - assert draft["DocumentLines"][0]["U_IDC_GRFLETE"] == "09T001-128912" + assert draft["CardCode"] == "PL10060028686" and draft["NumAtCard"] == "01F002-2450" - assert len(entorno.correos) == 1 - correo = entorno.correos[0] - assert correo["para"] == ["proveedor@empresa.com"] - assert correo["asunto"] == "Draft SAP B1 - F002-2450" - assert "creado exitosamente" in correo["html"] and "777" in correo["html"] - assert correo["cc"] # siempre se copia al equipo - assert any(a.endswith(".xml") for a in correo["adjuntos"]) + (respuesta,) = entorno.respuestas + assert respuesta["para"] == [SENDER] # al que envió el correo + assert respuesta["bcc"] == EQUIPO # el equipo en copia oculta + assert respuesta["cc"] is None and respuesta["adjuntos"] is None + assert respuesta["asunto"].startswith("Aceptado · Factura F002-2450 · ABR-") + assert "Registramos su factura" in respuesta["html"] + assert "09T001-128912" not in respuesta["html"] and "T001-128912" in respuesta["html"] + assert "S/ 624.38" in respuesta["html"] + assert "Pago estimado" in respuesta["html"] # hay condición de pago de 30 días -def test_precios_no_coinciden_no_crea_draft_pero_notifica(entorno): +def test_el_correo_tecnico_va_solo_al_equipo_nunca_al_remitente(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + + _procesar(entorno) + + (tecnico,) = entorno.tecnicos + assert tecnico["para"] == EQUIPO + assert SENDER not in tecnico["para"] and not tecnico["cc"] and not tecnico["bcc"] + assert any(a.endswith(".xml") for a in tecnico["adjuntos"]) # lleva los adjuntos internos + assert f"Remitente: {SENDER}" in tecnico["html"] and "ABR-" in tecnico["html"] + assert SENDER not in [d for c in entorno.respuestas for d in c["para"] if c["asunto"].startswith("[")] + + +def test_modo_prueba_es_el_predeterminado_y_no_escribe_al_remitente(entorno): + _procesar(entorno) # NOTIFICACION_MODO sin definir + + (respuesta,) = entorno.respuestas + assert respuesta["para"] == EQUIPO + assert respuesta["bcc"] is None + assert respuesta["asunto"].startswith(f"[PRUEBA → {SENDER}] Aceptado · Factura F002-2450") + assert all(SENDER not in c["para"] for c in entorno.correos) # a nadie fuera del equipo + + +# ============================================================================ +# Rechazado +# ============================================================================ +def test_precios_distintos_rechaza_y_muestra_ambos_importes(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") entorno.line_total_sap = TOTAL_LINEA + 50 _procesar(entorno) assert entorno.drafts == [] - assert len(entorno.correos) == 1 - assert "Draft NO creado" in entorno.correos[0]["html"] + (respuesta,) = entorno.respuestas + assert respuesta["para"] == [SENDER] + assert respuesta["asunto"].startswith("Rechazado · Factura F002-2450") + html = respuesta["html"] + assert "No pudimos registrar su factura" in html and "1 observación" in html + assert "El importe no coincide con nuestro registro" in html + assert "S/ 624.38" in html and "S/ 674.38" in html # importe de la factura y esperado + assert "No coincide" in html -def test_error_al_crear_draft_se_notifica(entorno): +def test_guia_inexistente_rechaza_con_guia_no_encontrada(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.guia_existe = False + + _procesar(entorno) + + (respuesta,) = entorno.respuestas + assert "No encontramos la guía T001-128912" in respuesta["html"] + assert entorno.drafts == [] + + +def test_guia_inexistente_con_hana_caido_es_error_interno_y_no_se_le_culpa_al_proveedor(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.guia_existe = False + entorno.hana_disponible = False + + _procesar(entorno) + + assert entorno.respuestas == [] # nada al proveedor + (tecnico,) = entorno.tecnicos + assert "HANA" in tecnico["html"] + (registro,) = _registros() + assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida" + + +def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.sap_lanza = RuntimeError("sin conexión") + + _procesar(entorno) + + assert entorno.respuestas == [] and entorno.drafts == [] + assert len(entorno.tecnicos) == 1 + assert _registros()[0]["estado"] == "error" + + +def test_error_al_crear_draft_avisa_solo_al_equipo(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") entorno.error_al_crear = RuntimeError("Service Layer caído") _procesar(entorno) - assert entorno.drafts == [] - assert len(entorno.correos) == 1 - assert "Error al crear Draft" in entorno.correos[0]["html"] - assert "Service Layer caído" in entorno.correos[0]["html"] + assert entorno.respuestas == [] + (tecnico,) = entorno.tecnicos + assert "Error al crear Draft" in tecnico["html"] and "Service Layer caído" in tecnico["html"] + (registro,) = _registros() + assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida" -def test_remitente_desconocido_no_envia_correo(entorno): +# ============================================================================ +# Correos sin comprobante válido +# ============================================================================ +def test_correo_con_adjuntos_que_no_son_pdf_ni_xml_se_rechaza(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {"foto.png": b"x", "nota.docx": b"y"} + + _procesar(entorno) + + (respuesta,) = entorno.respuestas + assert respuesta["para"] == [SENDER] + assert respuesta["asunto"].startswith("Rechazado · Comprobante no recibido · ABR-") + html = respuesta["html"] + assert "No recibimos el XML ni el PDF de su comprobante" in html + assert "Guías revisadas" not in html # sin tabla de guías vacía + assert "RUC —" not in html and "Factura electrónica" not in html # sin datos de comprobante inventados + assert html.count("RUC") == 1 # solo el del pie (RUC de A. Berio) + assert "{{" not in html + assert entorno.tecnicos == [] and entorno.drafts == [] + (registro,) = _registros() + assert registro["serie"] is None and registro["estado"] == "rechazado" + assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER + + +def test_solo_pdf_sin_xml_pide_el_xml(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + pdf = glob.glob(os.path.join(FIXTURES, "002_correo", "*.pdf"))[0] + entorno.archivos = {os.path.basename(pdf): open(pdf, "rb").read()} + + _procesar(entorno) + + (respuesta,) = entorno.respuestas + assert "Falta el archivo XML de su comprobante" in respuesta["html"] + + +def test_correo_sin_adjuntos_no_se_responde(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {} + + _procesar(entorno) + + assert entorno.correos == [] and _registros() == [] + + +@pytest.mark.parametrize("sender, subject, automatico, motivo", [ + ("no-reply@empresa.com", "Factura", False, "automática"), + ("buzon@aberio.com", "Factura", False, "propio buzón"), + ("persona@local", "Factura", False, "no válida"), + (SENDER, "Fuera de oficina", True, "automático"), + (SENDER, "Re: Aceptado · Factura F002-1 · ABR-261019-00042", False, "respuesta a una notificación"), + (SENDER, "RE: gracias", False, "respuesta a una notificación"), +]) +def test_correos_sin_comprobante_a_los_que_no_se_responde(entorno, monkeypatch, sender, subject, automatico, motivo): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {"foto.png": b"x"} + + _procesar(entorno, sender=sender, subject=subject, es_automatico=automatico) + + assert entorno.correos == [] + (registro,) = _registros() + assert registro["notificacion_estado"] == "omitida" and motivo in registro["notificacion_error"] + + +def test_limite_de_respuestas_por_remitente(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + entorno.archivos = {"foto.png": b"x"} + + for n in range(4): + _procesar(entorno, id_=f"correo-{n}", carpeta=entorno.descarga.parent / f"d{n}") + + assert [r["notificacion_estado"] for r in _registros()] == ["enviada", "enviada", "enviada", "omitida"] + assert len(entorno.respuestas) == 3 + + +# ============================================================================ +# Remitente y registro +# ============================================================================ +def test_remitente_desconocido_crea_el_draft_pero_no_responde(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + _procesar(entorno, sender="Desconocido") - assert len(entorno.drafts) == 1 # el draft se crea igual - assert entorno.correos == [] + assert len(entorno.drafts) == 1 + assert entorno.respuestas == [] and len(entorno.tecnicos) == 1 + assert _registros()[0]["notificacion_estado"] == "omitida" def test_correo_ya_procesado_se_salta(entorno, monkeypatch): @@ -137,8 +337,7 @@ def test_correo_ya_procesado_se_salta(entorno, monkeypatch): _procesar(entorno) - assert entorno.descargas == 0 - assert entorno.drafts == [] and entorno.correos == [] + assert entorno.descargas == 0 and entorno.drafts == [] and entorno.correos == [] def test_guarda_resultados_y_draft_en_disco(entorno): @@ -149,69 +348,50 @@ def test_guarda_resultados_y_draft_en_disco(entorno): assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres) -# ============================================================================ -# Seguimiento -# ============================================================================ -def _registros(): - conn = sqlite3.connect(database_utilities.DB_NAME) - conn.row_factory = sqlite3.Row - try: - return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")] - finally: - conn.close() +def test_aceptado_deja_el_resultado_y_la_notificacion_registrados(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") - -def test_draft_creado_deja_el_comprobante_aceptado(entorno): _procesar(entorno) (registro,) = _registros() - assert registro["estado"] == "aceptado" - assert registro["doc_entry"] == 777 - assert registro["codigo_seguimiento"].startswith("ABR-") - assert (registro["serie"], registro["numero"], registro["tipo"]) == ("F002", "2450", "01") - assert registro["ruc_emisor"] == "10060028686" + assert (registro["estado"], registro["doc_entry"], registro["tipo"]) == ("aceptado", 777, "01") + assert (registro["serie"], registro["numero"], registro["ruc_emisor"]) == ("F002", "2450", "10060028686") + assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER -def test_precios_que_no_coinciden_dejan_el_comprobante_rechazado(entorno): +def test_modo_prueba_registra_a_quien_se_envio_de_verdad(entorno): + _procesar(entorno) + + assert _registros()[0]["notificacion_destinatario"] == ", ".join(EQUIPO) + + +def test_rechazo_guarda_las_observaciones_completas(entorno): entorno.line_total_sap = TOTAL_LINEA + 50 _procesar(entorno) (registro,) = _registros() - assert registro["estado"] == "rechazado" - assert registro["doc_entry"] is None - assert seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] == [{"codigo": "precios_no_coinciden"}] - - -def test_error_al_crear_draft_deja_el_comprobante_en_error(entorno): - entorno.error_al_crear = RuntimeError("Service Layer caído") - - _procesar(entorno) - - (registro,) = _registros() - assert registro["estado"] == "error" - obs = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] - assert obs == [{"codigo": "error_sap", "detalle": "Service Layer caído"}] + (observacion,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] + assert observacion["codigo"] == "precio_no_coincide" + assert {"etiqueta": "Importe esperado", "valor": "S/ 674.38"} in observacion["filas"] def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog): _procesar(entorno) (primero,) = _registros() - entorno.descarga = entorno.descarga.parent / "descarga2" # otra descarga, otro correo with caplog.at_level(logging.WARNING, logger="pipeline"): - pipeline._procesar_un_correo( - {"id": "otro-correo", "fecha": "2026-10-19 11:00:00", "subject": "Reenvío", "sender": "p@e.com"}, - "token", "buzon@aberio.com", str(entorno.descarga), str(entorno.salida), - ) + _procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2") - (registro,) = _registros() # sigue habiendo una sola fila + (registro,) = _registros() # sigue habiendo una sola fila assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"] assert registro["veces_recibido"] == 2 assert any("ya recibido antes" in r.getMessage() for r in caplog.records) -def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch): +def test_si_falla_el_seguimiento_igual_se_crea_el_draft_pero_no_se_responde(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + def roto(*a, **k): raise sqlite3.OperationalError("database is locked") @@ -221,4 +401,15 @@ def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch): _procesar(entorno) assert len(entorno.drafts) == 1 - assert len(entorno.correos) == 1 + assert len(entorno.tecnicos) == 1 and entorno.respuestas == [] # sin código no se responde al proveedor + + +def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + monkeypatch.setattr(pipeline.draft_sap, "build_sap_draft", lambda *a, **k: 1 / 0) + + _procesar(entorno) # no debe lanzar excepción + + assert entorno.drafts == [] and entorno.respuestas == [] + assert "ZeroDivisionError" in entorno.tecnicos[0]["html"] + assert _registros()[0]["estado"] == "error" diff --git a/tests/test_respuesta_proveedor.py b/tests/test_respuesta_proveedor.py new file mode 100644 index 0000000..70ac9a5 --- /dev/null +++ b/tests/test_respuesta_proveedor.py @@ -0,0 +1,270 @@ +import logging + +import pytest + +import observaciones as obs +import outlook_utilities +import plantillas +import respuesta_proveedor as rp +import seguimiento + +FACTURA = { + "serie": "F002", "numero": "486", "tipo": "01", "ruc_emisor": "20537929520", + "razon_social_emisor": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.", + "lineas": [ + {"total_linea": 624.38, "guias_remision": [{"serie": "T001", "numero": "1"}, {"serie": "T001", "numero": "2"}]}, + {"total_linea": 100.0, "guias_remision": [{"serie": "T001", "numero": "3"}]}, + ], +} +RESULTADOS = [ + {"ok": True, "guias_lt": [{"guia": {"serie": "T001", "numero": "1"}}, {"guia": {"serie": "T001", "numero": "2"}}]}, + {"ok": False, "guias_lt": [{"guia": {"serie": "T001", "numero": "3"}}]}, +] +EMAIL = {"id": "c1", "fecha": "2026-10-14 10:35:00", "subject": "Factura", "sender": "prov@empresa.com"} + + +@pytest.fixture +def registro(): + return seguimiento.registrar_comprobante("c1", "20537929520", "01", "F002", "486") + + +# ============================================================================ +# Configuración +# ============================================================================ +@pytest.mark.parametrize("valor, esperado", [(None, "prueba"), ("", "prueba"), ("real", "real"), (" REAL ", "real"), + ("prueba", "prueba"), ("produccion", "prueba")]) +def test_modo(monkeypatch, valor, esperado): + if valor is not None: + monkeypatch.setenv("NOTIFICACION_MODO", valor) + assert rp.modo() == esperado + + +def test_equipo(monkeypatch): + assert rp.equipo() == rp.EQUIPO_POR_DEFECTO + monkeypatch.setenv("NOTIFICACION_EQUIPO", " a@x.com ; b@x.com,c@x.com ,, ") + assert rp.equipo() == ["a@x.com", "b@x.com", "c@x.com"] + + +def test_contacto_usa_el_buzon_si_no_se_define(monkeypatch): + assert rp.contacto_correo() == "buzon@aberio.com" + monkeypatch.setenv("NOTIFICACION_CONTACTO", "ayuda@aberio.com") + assert rp.contacto_correo() == "ayuda@aberio.com" + + +# ============================================================================ +# Protecciones +# ============================================================================ +BUZON = "buzon@aberio.com" + + +def _correo(**kv): + return {**EMAIL, **kv} + + +@pytest.mark.parametrize("sender", [None, "", "Desconocido"]) +def test_sin_remitente(sender): + assert "desconocido" in rp.motivo_omision(_correo(sender=sender), BUZON) + + +@pytest.mark.parametrize("sender", ["test@local", "sin-arroba", "a@b", "a b@c.com"]) +def test_direcciones_no_validas(sender): + assert "no válida" in rp.motivo_omision(_correo(sender=sender), BUZON) + + +def test_el_propio_buzon_aunque_cambie_la_capitalizacion(): + assert "propio buzón" in rp.motivo_omision(_correo(sender="Buzon@ABERIO.com"), BUZON) + + +@pytest.mark.parametrize("sender", ["no-reply@x.com", "noreply@x.com", "No_Reply@x.com", "donotreply@x.com", + "do-not-reply@x.com", "mailer-daemon@x.com", "postmaster@x.com", "bounces@x.com", + "no-responder@x.com"]) +def test_direcciones_automaticas(sender): + assert "automática" in rp.motivo_omision(_correo(sender=sender), BUZON) + + +def test_una_direccion_normal_con_palabras_parecidas_si_se_responde(): + assert rp.motivo_omision(_correo(sender="reply.compras@empresa.com"), BUZON) is None + assert rp.motivo_omision(_correo(sender="ventas@empresa.com"), BUZON) is None + + +def test_mensaje_automatico(): + assert "automático" in rp.motivo_omision(_correo(es_automatico=True), BUZON) + + +def test_respuestas_a_notificaciones_solo_se_omiten_si_no_hay_comprobante(): + respuesta = _correo(subject="Re: Aceptado · Factura F002-1 · ABR-261019-00042") + assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=True) is not None + assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=False) is None # con comprobante se procesa normal + assert rp.motivo_omision(_correo(subject="RV: documentos"), BUZON, sin_comprobante=True) is not None + assert rp.motivo_omision(_correo(subject="Factura ABR-261019-00042"), BUZON, sin_comprobante=True) is not None + assert rp.motivo_omision(_correo(subject="Factura de octubre"), BUZON, sin_comprobante=True) is None + + +# ============================================================================ +# Contextos +# ============================================================================ +def test_guias_el_importe_de_la_linea_va_una_vez(): + filas = rp.guias_para_plantilla(FACTURA, RESULTADOS) + + assert filas == [ + {"guia": "T001-1", "importe_factura": "S/ 624.38", "conforme": True}, + {"guia": "T001-2", "importe_factura": "incluida en la línea", "conforme": True}, + {"guia": "T001-3", "importe_factura": "S/ 100.00", "conforme": False}, + ] + + +def test_guias_sin_resultado_de_validacion_usa_las_del_xml_como_no_conformes(): + filas = rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": [{"serie": "T001", "numero": "9"}]}]}, []) + assert filas == [{"guia": "T001-9", "importe_factura": "S/ 5.00", "conforme": False}] + + +def test_linea_sin_guias_no_genera_filas(): + assert rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": []}]}, [{"ok": False, "guias_lt": []}]) == [] + + +@pytest.mark.parametrize("vence, esperado", [("2026-11-13", "13/11/2026"), ("2026-10-14", None), ("2026-10-01", None)]) +def test_fecha_estimada_de_pago_solo_si_hay_plazo(registro, vence, esperado): + draft = {"DocDate": "2026-10-14", "DocDueDate": vence} + contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, draft) + assert contexto["fecha_estimada_pago"] == esperado + + +def test_contexto_aceptado(registro): + contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {}) + + assert contexto["asunto"] == f"Aceptado · Factura F002-486 · {registro['codigo_seguimiento']}" + assert contexto["recepcion_fecha"] == "14/10/2026" and contexto["recepcion_hora"] == "10:35" + assert contexto["comprobante_tipo"] == "Factura electrónica" and contexto["proveedor_ruc"] == "20537929520" + assert contexto["fecha_estimada_pago"] is None + + +def test_contexto_rechazo_numera_las_observaciones(registro): + lista = [obs.guia_no_encontrada("T001-3"), obs.precio_no_coincide(2, 100, 120, ["T001-3"], True)] + contexto = rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista) + + assert contexto["observaciones_texto"] == "2 observaciones" + assert [o["numero"] for o in contexto["observaciones"]] == [1, 2] + assert contexto["observaciones"][0]["filas"] == [{"etiqueta": "Guía", "valor": "T001-3"}] + assert contexto["tiene_guias"] is True and contexto["preheader"].startswith("Tiene 2 observaciones.") + + +def test_contexto_rechazo_de_correo_sin_comprobante(registro): + contexto = rp.contexto_rechazo_correo(EMAIL, registro, [obs.sin_pdf_xml(1)]) + + assert contexto["asunto"] == f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}" + assert contexto["comprobante_numero"] is None and contexto["proveedor_ruc"] is None + assert contexto["proveedor_razon_social"] == "prov@empresa.com" + assert contexto["guias"] == [] and contexto["tiene_guias"] is False + + +def test_los_tres_contextos_renderizan_sin_variables_faltantes(registro): + lista = [obs.sin_pdf_xml(1)] + for plantilla, contexto in [ + ("aceptado", rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {"DocDate": "2026-10-14", "DocDueDate": "2026-11-13"})), + ("rechazado", rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista)), + ("rechazado", rp.contexto_rechazo_correo(EMAIL, registro, lista)), + ]: + html = plantillas.renderizar_plantilla(plantilla, contexto) + assert "{{" not in html and registro["codigo_seguimiento"] in html + + +def test_razon_social_con_ampersand_sale_escapada(registro): + contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {}) + assert "SÁNCHEZ & HIJOS" in plantillas.renderizar_plantilla("aceptado", contexto) + + +# ============================================================================ +# responder() +# ============================================================================ +class _Enviados(list): + """Correos que recibió Graph; ``ok`` controla si Graph los acepta.""" + ok = True + + +@pytest.fixture +def graph(monkeypatch): + enviados = _Enviados() + + def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None): + enviados.append({"para": para, "bcc": bcc_recipients, "asunto": asunto}) + return enviados.ok + + monkeypatch.setattr(outlook_utilities, "send_email", enviar) + monkeypatch.setenv("NOTIFICACION_EQUIPO", "eq@aberio.com") + return enviados + + +def _contexto(registro): + return rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {}) + + +def test_responder_en_modo_real(graph, registro, monkeypatch): + monkeypatch.setenv("NOTIFICACION_MODO", "real") + + assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada" + + assert graph == [{"para": ["prov@empresa.com"], "bcc": ["eq@aberio.com"], "asunto": _contexto(registro)["asunto"]}] + fila = seguimiento.obtener(registro["codigo_seguimiento"]) + assert (fila["notificacion_estado"], fila["notificacion_destinatario"]) == ("enviada", "prov@empresa.com") + + +def test_responder_en_modo_prueba(graph, registro): + assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada" + + (envio,) = graph + assert envio["para"] == ["eq@aberio.com"] and envio["bcc"] is None + assert envio["asunto"].startswith("[PRUEBA → prov@empresa.com] Aceptado") + + +def test_responder_omite_y_anota_el_motivo(graph, registro): + estado = rp.responder("t", BUZON, _correo(sender="no-reply@x.com"), "aceptado", _contexto(registro), registro) + + assert estado == "omitida" and graph == [] + fila = seguimiento.obtener(registro["codigo_seguimiento"]) + assert fila["notificacion_estado"] == "omitida" and "automática" in fila["notificacion_error"] + + +def test_responder_si_graph_falla(graph, registro): + graph.ok = False + + assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "fallida" + + fila = seguimiento.obtener(registro["codigo_seguimiento"]) + assert fila["notificacion_estado"] == "fallida" and "Graph" in fila["notificacion_error"] + + +def test_responder_si_la_plantilla_no_se_puede_armar_no_envia_nada(graph, registro): + contexto = _contexto(registro) + del contexto["codigo_seguimiento"] + + assert rp.responder("t", BUZON, EMAIL, "aceptado", contexto, registro) == "fallida" + + assert graph == [] + assert "codigo_seguimiento" in seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_error"] + + +def test_el_limite_solo_aplica_a_correos_sin_comprobante(graph, monkeypatch): + import database_utilities + monkeypatch.setenv("NOTIFICACION_MODO", "real") + database_utilities.save_emails([{"id": f"e{n}", "fecha": "2026-10-14 10:00:00", "sender": "prov@empresa.com", + "subject": "s", "has_attach": True} for n in range(5)]) + + estados = [] + for n in range(5): + reg = seguimiento.registrar_correo(f"e{n}") + email = _correo(id=f"e{n}") + estados.append(rp.responder("t", BUZON, email, "rechazado", rp.contexto_rechazo_correo(email, reg, [obs.sin_pdf_xml(1)]), + reg, sin_comprobante=True)) + + assert estados == ["enviada"] * rp.MAX_RESPUESTAS_CORREO + ["omitida"] * (5 - rp.MAX_RESPUESTAS_CORREO) + + +def test_encabezado_tecnico(registro): + html = rp.encabezado_tecnico(EMAIL, registro) + assert "prov@empresa.com" in html and registro["codigo_seguimiento"] in html and "prueba" in html + assert "sin código" in rp.encabezado_tecnico(EMAIL, None) + + +def test_enviar_a_equipo(graph): + assert rp.enviar_a_equipo("t", BUZON, "Asunto", "", ["a.xml"]) is True + assert graph[0]["para"] == ["eq@aberio.com"] diff --git a/tests/test_seguimiento.py b/tests/test_seguimiento.py index 0bf448e..182fcce 100644 --- a/tests/test_seguimiento.py +++ b/tests/test_seguimiento.py @@ -147,3 +147,66 @@ def test_la_tabla_emails_existente_no_se_modifica(): database_utilities.init_db() assert seguimiento.registrar_correo("correo-1")["codigo_seguimiento"].startswith("ABR-") + + +def test_registrar_notificacion_guarda_el_destinatario(): + r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE) + seguimiento.registrar_notificacion(r["codigo_seguimiento"], "enviada", destinatario="a@x.com, b@x.com") + assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_destinatario"] == "a@x.com, b@x.com" + + +def _correo_con_respuesta(email_id, sender, estado="enviada", comprobante=False): + database_utilities.save_emails([{"id": email_id, "fecha": "2026-10-14 10:00:00", "sender": sender, + "subject": "s", "has_attach": True}]) + r = (seguimiento.registrar_comprobante(email_id, *COMPROBANTE[:2], "F002", email_id) if comprobante + else seguimiento.registrar_correo(email_id)) + seguimiento.registrar_notificacion(r["codigo_seguimiento"], estado) + return r + + +def test_contar_respuestas_solo_cuenta_enviadas_a_correos_sin_comprobante_de_ese_remitente(): + _correo_con_respuesta("e1", "Prov@Empresa.com") # cuenta (sin distinguir mayúsculas) + _correo_con_respuesta("e2", "prov@empresa.com") # cuenta + _correo_con_respuesta("e3", "prov@empresa.com", estado="omitida") # no: no se envió + _correo_con_respuesta("e4", "prov@empresa.com", estado="fallida") # no: no se envió + _correo_con_respuesta("e5", "otro@empresa.com") # no: otro remitente + _correo_con_respuesta("e6", "prov@empresa.com", comprobante=True) # no: tiene comprobante + + assert seguimiento.contar_respuestas_correo("prov@empresa.com") == 2 + assert seguimiento.contar_respuestas_correo("nadie@empresa.com") == 0 + + +def test_contar_respuestas_ignora_las_anteriores_a_la_ventana(): + r = _correo_con_respuesta("e1", "prov@empresa.com") + conn = sqlite3.connect(database_utilities.DB_NAME) + conn.execute("UPDATE comprobantes SET notificacion_fecha = '2020-01-01 00:00:00' WHERE codigo_seguimiento = ?", + (r["codigo_seguimiento"],)) + conn.commit() + conn.close() + + assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24) == 0 + assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24 * 365 * 10) == 1 + + +def test_init_db_agrega_la_columna_a_una_tabla_creada_antes(): + conn = sqlite3.connect(database_utilities.DB_NAME) + conn.executescript(""" + DROP TABLE comprobantes; + CREATE TABLE comprobantes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, codigo_seguimiento TEXT UNIQUE, email_id TEXT NOT NULL, + ruc_emisor TEXT, tipo TEXT, serie TEXT, numero TEXT, estado TEXT NOT NULL DEFAULT 'pendiente', + observaciones TEXT, doc_entry INTEGER, veces_recibido INTEGER NOT NULL DEFAULT 1, + notificacion_estado TEXT NOT NULL DEFAULT 'pendiente', notificacion_error TEXT, notificacion_fecha TEXT, + creado_en TEXT NOT NULL, actualizado_en TEXT NOT NULL); + INSERT INTO comprobantes (codigo_seguimiento, email_id, creado_en, actualizado_en) + VALUES ('ABR-261001-00001', 'viejo', '2026-10-01 10:00:00', '2026-10-01 10:00:00'); + """) + conn.commit() + conn.close() + + database_utilities.init_db() + + assert seguimiento.obtener("ABR-261001-00001")["email_id"] == "viejo" # el dato se conserva + seguimiento.registrar_notificacion("ABR-261001-00001", "enviada", destinatario="a@x.com") + assert seguimiento.obtener("ABR-261001-00001")["notificacion_destinatario"] == "a@x.com" +