se responde al remitente con las plantillas de aceptado/rechazado
El sistema contesta a quien envió el correo con la plantilla que corresponda. Por defecto (NOTIFICACION_MODO=prueba) la respuesta va solo al equipo con el asunto "[PRUEBA → remitente]"; en modo real va al remitente con copia oculta al equipo. El correo técnico (tablas de SAP, JSON) pasa a ir solo al equipo. - respuesta_proveedor.py: contextos de las plantillas, envío y protecciones (remitente desconocido o sin dominio, propio buzón, no-reply, mensajes automáticos, respuestas a notificaciones, límite de 3 por remitente/24 h). - observaciones.py: motivos de rechazo con título, filas y cómo corregir (borrador de textos); también para correos sin comprobante válido. - Un error de SAP/HANA o un fallo inesperado no se le comunica al proveedor y ya no tumba el ciclo; "guía no encontrada" exige que HANA responda. - outlook_utilities: es_mensaje_automatico y copia oculta en send_email. - seguimiento: notificacion_destinatario (con migración) y contar_respuestas_correo. - config_validator anuncia el modo activo; documentación y pruebas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
af9e3961e5
commit
4deb9d4e4d
22 files changed
+1644
-130
No files matched your search
@@ -26,6 +26,7 @@ HANA_PASSWORD =
|
|||||||
HANA_SCHEMA =
|
HANA_SCHEMA =
|
||||||
|
|
||||||
# --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) ---
|
# --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) ---
|
||||||
|
# Cómo obtener estos valores: docs/configurar_msal.md
|
||||||
CLIENT_ID =
|
CLIENT_ID =
|
||||||
CLIENT_SECRET =
|
CLIENT_SECRET =
|
||||||
TENANT_ID =
|
TENANT_ID =
|
||||||
@@ -33,3 +34,14 @@ VERIFY_SSL = false
|
|||||||
|
|
||||||
# --- Logging (ver logger_manager.py). Opcional, por defecto INFO ---
|
# --- Logging (ver logger_manager.py). Opcional, por defecto INFO ---
|
||||||
LOG_LEVEL = 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 =
|
||||||
@@ -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
|
→ 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)
|
→ 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
|
→ 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).
|
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 |
|
| `main.py` | Demonio y horarios |
|
||||||
| `pipeline.py` | Orquesta el proceso de cada correo |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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/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/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/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/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/outlook_utilities.md`](docs/outlook_utilities.md) | Cliente de Microsoft Graph |
|
||||||
| [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA |
|
| [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA |
|
||||||
@@ -21,6 +21,9 @@ OBLIGATORIAS = {
|
|||||||
"SAP HANA": ["HANA_SERVER", "HANA_USER", "HANA_PASSWORD", "HANA_SCHEMA"],
|
"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
|
# 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).
|
# la activa con el valor "true" (cualquier otro valor, o ausente, la desactiva).
|
||||||
VERIFICACION_SSL = {
|
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"
|
"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()
|
nivel = os.getenv("LOG_LEVEL", "").strip()
|
||||||
if nivel and not isinstance(getattr(logging, nivel.upper(), None), int):
|
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")
|
advertencias.append(f"LOG_LEVEL='{nivel}' no es un nivel válido; se usará INFO")
|
||||||
|
|||||||
@@ -47,10 +47,16 @@ def init_db() -> None:
|
|||||||
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente',
|
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente',
|
||||||
notificacion_error TEXT,
|
notificacion_error TEXT,
|
||||||
notificacion_fecha TEXT,
|
notificacion_fecha TEXT,
|
||||||
|
notificacion_destinatario TEXT,
|
||||||
creado_en TEXT NOT NULL,
|
creado_en TEXT NOT NULL,
|
||||||
actualizado_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('''
|
cursor.execute('''
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento
|
||||||
ON comprobantes (ruc_emisor, tipo, serie, numero) WHERE serie IS NOT NULL
|
ON comprobantes (ruc_emisor, tipo, serie, numero) WHERE serie IS NOT NULL
|
||||||
|
|||||||
+10
-7
@@ -5,27 +5,30 @@
|
|||||||
| Archivo | Líneas | Rol |
|
| Archivo | Líneas | Rol |
|
||||||
|---------|--------|-----|
|
|---------|--------|-----|
|
||||||
| `main.py` | 137 | Demonio orquestador (loop de horarios) |
|
| `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()` |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `notificaciones.py` | 117 | Cuerpos HTML de los correos de notificación |
|
||||||
| `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
|
| `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
|
||||||
| `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) |
|
| `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) |
|
||||||
| `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer |
|
| `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) |
|
| `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/*_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`) |
|
| `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`) |
|
| `seguimiento.py` | 206 | 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) |
|
| `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) |
|
| `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) |
|
||||||
| `logger_manager.py` | 150 | Logging a archivo rotado (30 días) + consola + captura de `print()` |
|
| `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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# outlook_utilities.py — Microsoft Graph (Outlook)
|
# 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
|
## Estructura
|
||||||
|
|
||||||
@@ -19,9 +19,9 @@ outlook_utilities.py
|
|||||||
| Función | Endpoint Graph | Qué hace |
|
| 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_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. |
|
| `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
|
## 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.
|
- `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).
|
- 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.
|
- `get_access_token` retorna `None` (sin lanzar error) si MSAL devuelve un resultado vacío.
|
||||||
|
- `es_mensaje_automatico(headers)` marca como automáticos los mensajes con `Auto-Submitted` distinto de `no`, `X-Auto-Response-Suppress`, `X-Autoreply`, `Precedence` (`bulk`, `junk`, `list`, `auto_reply`) o `Content-Type` de informe de entrega (rebotes). El sistema no responde a esos mensajes.
|
||||||
|
|
||||||
+35
-9
@@ -14,7 +14,10 @@ Módulos que orquesta _procesar_un_correo():
|
|||||||
├── documentos.py procesar_pdfs, procesar_xmls, completar_guias, validar_guias, copiar_a_red
|
├── documentos.py procesar_pdfs, procesar_xmls, completar_guias, validar_guias, copiar_a_red
|
||||||
├── persistencia.py guardar_resultado, guardar_draft_json
|
├── persistencia.py guardar_resultado, guardar_draft_json
|
||||||
├── draft_sap.py validate_prices, obtener_valor_referencial_sap, build_sap_draft
|
├── 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
|
└── pipeline_utils.py log, ruta_carpeta
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -54,29 +57,34 @@ flowchart TD
|
|||||||
F -- Sí --> G[4. _descomprimir ZIPs]
|
F -- Sí --> G[4. _descomprimir ZIPs]
|
||||||
G --> H[5. filtrar_y_limpiar]
|
G --> H[5. filtrar_y_limpiar]
|
||||||
H --> I{Hay PDF/XML?}
|
H --> I{Hay PDF/XML?}
|
||||||
I -- No --> C
|
I -- No --> RC[Rechazo del correo<br>responder al remitente]
|
||||||
I -- Sí --> J[6. procesar_pdfs]:::highlight
|
I -- Sí --> J[6. procesar_pdfs]:::highlight
|
||||||
J --> K[7. procesar_xmls]:::highlight
|
J --> K[7. procesar_xmls]:::highlight
|
||||||
K --> K2[8. completar_guias]
|
K --> K2[8. completar_guias]
|
||||||
K2 --> K3[9. validar_guias]
|
K2 --> K3[9. validar_guias]
|
||||||
K3 --> L{Hay documentos?}
|
K3 --> L{Hay documentos?}
|
||||||
L -- No --> C
|
L -- No --> RC
|
||||||
L -- Sí --> M[10. guardar_resultado]
|
L -- Sí --> M[10. guardar_resultado]
|
||||||
M --> N[11. Filtrar facturas tipo 01]
|
M --> N[11. Filtrar facturas tipo 01]
|
||||||
N --> O{Hay facturas?}
|
N --> O{Hay facturas?}
|
||||||
O -- No --> C
|
O -- No --> RC
|
||||||
O -- Sí --> P[Bucle: por cada factura]
|
O -- Sí --> P[Bucle: por cada factura]
|
||||||
|
|
||||||
P --> Q[12. validate_prices]:::highlight
|
P --> Q[12. validate_prices]:::highlight
|
||||||
Q --> Q2[12.5 obtener_valor_referencial_sap]
|
Q --> Q2[12.5 obtener_valor_referencial_sap]
|
||||||
Q2 --> R[13. build_sap_draft]:::highlight
|
Q2 --> R[13. build_sap_draft]:::highlight
|
||||||
R --> S[14. guardar_draft_json]
|
R --> S[14. guardar_draft_json]
|
||||||
S --> T{Todas coinciden?}
|
S --> T{¿Error de SAP/HANA?}
|
||||||
T -- Sí --> U[15. sapb1_manager.create_draft]
|
T -- Sí --> X[estado error]
|
||||||
T -- No --> V[⏭️ Draft NO creado]
|
T -- No --> T2{Todas coinciden?}
|
||||||
U --> W[16. outlook_utilities.send_email]
|
T2 -- Sí --> U[15. sapb1_manager.create_draft]
|
||||||
|
T2 -- No --> V[estado rechazado + observaciones]
|
||||||
|
U --> U2[estado aceptado]
|
||||||
|
U2 --> W[16. Correo técnico al equipo<br>+ respuesta con plantilla al remitente]
|
||||||
V --> W
|
V --> W
|
||||||
|
X --> W2[16. Correo técnico al equipo<br>NO se responde al proveedor]
|
||||||
W --> P
|
W --> P
|
||||||
|
W2 --> P
|
||||||
|
|
||||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||||
```
|
```
|
||||||
@@ -174,5 +182,23 @@ run_pipeline
|
|||||||
│ ├── guardar_draft_json
|
│ ├── guardar_draft_json
|
||||||
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
|
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
|
||||||
│ ├── build_notification_body
|
│ ├── 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.
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# respuesta_proveedor.py — Respuesta al proveedor
|
||||||
|
|
||||||
|
Cuando alguien envía un correo con el XML y el PDF de una factura, el sistema valida y **responde a esa misma dirección** con la plantilla de **aceptado** o de **rechazado** (`plantilla/plantilla-aceptado.html` y `plantilla-rechazado.html`). Aparte, el equipo recibe un correo técnico (tablas de SAP, JSON adjuntos).
|
||||||
|
|
||||||
|
Piezas: `respuesta_proveedor.py` (contexto, protecciones, envío), `observaciones.py` (motivos de rechazo y sus textos), `plantillas.py` (renderizador) y `seguimiento.py` (código de seguimiento y registro).
|
||||||
|
|
||||||
|
## Qué se responde
|
||||||
|
|
||||||
|
| Situación | Respuesta al remitente |
|
||||||
|
|-----------|------------------------|
|
||||||
|
| Draft creado en SAP | **Aceptado** |
|
||||||
|
| Precios que no coinciden, guía inexistente, línea sin guía, cantidad cero | **Rechazado** con una observación por motivo |
|
||||||
|
| Correo con adjuntos pero sin PDF ni XML válido, o con PDF y sin XML, o sin ninguna factura (tipo 01) | **Rechazado** sin comprobante: no muestra proveedor, número ni tabla de guías |
|
||||||
|
| Error de SAP o de HANA, o un fallo inesperado | **Nada**: es una falla nuestra. Solo se avisa al equipo |
|
||||||
|
| Correo sin adjuntos | **Nada** |
|
||||||
|
|
||||||
|
Una respuesta por comprobante: un correo con 3 facturas genera 3 respuestas. La aceptación significa "draft creado en SAP". La diferencia de valor referencial XML vs SAP es solo informativa y no rechaza.
|
||||||
|
|
||||||
|
> **Guía "no encontrada" y caídas de HANA.** `get_delivery_notes` devuelve lista vacía tanto si la guía no existe como si SAP/HANA fallan. Antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, se trata como error interno y no se le dice nada al proveedor.
|
||||||
|
|
||||||
|
## Modo prueba y modo real
|
||||||
|
|
||||||
|
`NOTIFICACION_MODO` en el `.env`:
|
||||||
|
|
||||||
|
| Modo | A quién va la respuesta | Asunto |
|
||||||
|
|------|-------------------------|--------|
|
||||||
|
| `prueba` (**por defecto**) | Al equipo (`NOTIFICACION_EQUIPO`), **nunca** al remitente | `[PRUEBA → remitente] Aceptado · Factura F002-486 · ABR-…` |
|
||||||
|
| `real` | Al remitente, con **copia oculta** al equipo | `Aceptado · Factura F002-486 · ABR-…` |
|
||||||
|
|
||||||
|
Cualquier otro valor se trata como `prueba`. Al arrancar, `config_validator` deja en el log una advertencia con el modo activo. Pasar a `real` es una decisión explícita: no ocurre solo.
|
||||||
|
|
||||||
|
## Protecciones: cuándo NO se responde
|
||||||
|
|
||||||
|
El sistema contesta solo y a terceros, así que no responde a:
|
||||||
|
|
||||||
|
| Caso | Motivo registrado |
|
||||||
|
|------|-------------------|
|
||||||
|
| Remitente vacío o desconocido | remitente desconocido |
|
||||||
|
| Dirección sin dominio válido (por ejemplo `test@local`) | dirección no válida |
|
||||||
|
| El propio buzón del sistema | el remitente es el propio buzón |
|
||||||
|
| `no-reply`, `noreply`, `do-not-reply`, `no-responder`, `mailer-daemon`, `postmaster`, `bounce` | dirección automática |
|
||||||
|
| Mensajes automáticos (respuestas automáticas, rebotes, listas) | mensaje automático |
|
||||||
|
| En correos **sin comprobante**: asunto con `Re:`/`RV:` o que cita un código `ABR-…` | parece una respuesta a una notificación nuestra |
|
||||||
|
| En correos sin comprobante: más de 3 respuestas al mismo remitente en 24 h | límite de respuestas |
|
||||||
|
|
||||||
|
Cada omisión queda en `comprobantes` como `notificacion_estado = omitida`, con el motivo.
|
||||||
|
|
||||||
|
## Qué ve el proveedor
|
||||||
|
|
||||||
|
- **Importe por guía:** el importe de la línea aparece una vez, en su primera guía; las demás dicen "incluida en la línea". Todas las guías de una línea llevan el mismo resultado, porque la validación es por línea.
|
||||||
|
- **Rechazo por precio:** se muestran "Importe en su factura" e "Importe esperado" (el de SAP).
|
||||||
|
- **Pago estimado:** solo si hay condición de pago (fecha de vencimiento posterior a la de contabilización).
|
||||||
|
- **Sin adjuntos internos:** la respuesta no lleva archivos.
|
||||||
|
|
||||||
|
## Observaciones (`observaciones.py`)
|
||||||
|
|
||||||
|
Los textos son un **borrador para revisar**; se cambian en ese archivo sin tocar la lógica.
|
||||||
|
|
||||||
|
| Código | Cuándo |
|
||||||
|
|--------|--------|
|
||||||
|
| `precio_no_coincide` | El total de la línea del XML no coincide con la suma de SAP |
|
||||||
|
| `guia_no_encontrada` | SAP no devuelve datos para la guía |
|
||||||
|
| `linea_sin_guia` | Una línea no indica su guía de remisión |
|
||||||
|
| `cantidad_cero` | Una línea tiene cantidad cero |
|
||||||
|
| `sin_pdf_xml` | El correo trae adjuntos, pero ninguno es PDF ni XML |
|
||||||
|
| `falta_xml` | Hay PDF y ningún XML |
|
||||||
|
| `sin_factura` | El XML no contiene una factura electrónica (tipo 01) |
|
||||||
|
|
||||||
|
## Configuración (`.env`)
|
||||||
|
|
||||||
|
| Variable | Para qué | Por defecto |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| `NOTIFICACION_MODO` | `prueba` o `real` | `prueba` |
|
||||||
|
| `NOTIFICACION_EQUIPO` | Direcciones internas (coma o `;`): correo técnico, y respuestas en modo prueba | `jlopez@aberio.com.pe`, `gcalle@pucp.pe` |
|
||||||
|
| `NOTIFICACION_CONTACTO` | Dirección del botón "Escribir a Aberio" | el buzón `EMAIL` |
|
||||||
|
| `LOGO_URL` | URL pública (https) del logo | vacío: sin logo |
|
||||||
|
|
||||||
|
El buzón `EMAIL` es también el `envio_correo` al que se pide reenviar el comprobante corregido.
|
||||||
|
|
||||||
|
## Cómo probar
|
||||||
|
|
||||||
|
1. **Vista previa y envío de ejemplos:** `python tools/previsualizar_plantillas.py` (ver su ayuda). Los ejemplos están en `plantilla/ejemplos.json`.
|
||||||
|
2. **Piloto en modo prueba:** con `NOTIFICACION_MODO=prueba` el flujo completo corre y las respuestas llegan solo al equipo, indicando en el asunto a quién habrían ido. El correo técnico también.
|
||||||
|
3. **Pasar a real:** cuando el equipo valide los correos, cambiar a `NOTIFICACION_MODO=real`. Revisar antes que `LOGO_URL` tenga valor.
|
||||||
|
|
||||||
|
## Limitaciones conocidas
|
||||||
|
|
||||||
|
- **Un fallo de envío no se reintenta.** Queda `fallida` en `comprobantes`, pero el correo ya está marcado como procesado.
|
||||||
|
- **Si SAP está caído, el correo tampoco se reintenta.** Se avisa al equipo y el correo queda procesado (con estado `error`).
|
||||||
|
- **Un comprobante ya aceptado que se reenvía** genera otro draft y otra respuesta "Aceptado"; hoy solo se advierte en el log.
|
||||||
|
- La respuesta va a la dirección `from` del mensaje. Si alguien reenvía la factura de un proveedor, la respuesta le llega a quien reenvió.
|
||||||
|
- No se prueba contra el logo real ni contra Outlook de escritorio: hay que revisar los correos a mano.
|
||||||
+11
-4
@@ -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 |
|
| `doc_entry` | DocEntry del draft creado en SAP |
|
||||||
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
|
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
|
||||||
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida` u `omitida`, y su motivo |
|
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida` 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 |
|
| `creado_en`, `actualizado_en` | Marcas de tiempo |
|
||||||
|
|
||||||
## Funciones
|
## 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. |
|
| `registrar_correo(email_id)` | Igual, para un correo sin comprobante. Idempotente por correo. |
|
||||||
| `obtener(codigo)` | Devuelve el registro o `None`. |
|
| `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. |
|
| `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
|
## 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"}]` |
|
| Precios no coinciden con SAP | `rechazado` | `[{"codigo": "precios_no_coinciden"}]` |
|
||||||
| Error al crear el draft | `error` | `[{"codigo": "error_sap", "detalle": "..."}]` |
|
| 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.
|
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.
|
- Rechazo de una factura: la lista de observaciones completa (`codigo`, `titulo`, `filas`, `como_corregir`; ver `observaciones.py`).
|
||||||
- `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.
|
- 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
|
## Notas
|
||||||
|
|
||||||
|
|||||||
+30
-1
@@ -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:
|
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
|
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)
|
all_wh.update(wh_set)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
log(f"⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}", 2, severity=logging.WARNING)
|
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
|
all_ok = False
|
||||||
|
|
||||||
sap_total = sum(item["line_total"] for item in guias_lt)
|
sap_total = sum(item["line_total"] for item in guias_lt)
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
Catálogo de observaciones: los motivos por los que se rechaza un comprobante.
|
||||||
|
|
||||||
|
Cada observación se muestra al proveedor en la plantilla de rechazo con un
|
||||||
|
título, unas filas de detalle y un texto de "cómo corregir". **Los textos de
|
||||||
|
este archivo son un borrador** para revisar y ajustar; no hace falta tocar la
|
||||||
|
lógica para cambiarlos.
|
||||||
|
|
||||||
|
``desde_validacion`` convierte el resultado de ``draft_sap.validate_prices`` en
|
||||||
|
observaciones; el resto son los motivos de un correo sin comprobante válido.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Observacion:
|
||||||
|
codigo: str # identificador estable (se guarda en la BD)
|
||||||
|
titulo: str
|
||||||
|
filas: tuple[tuple[str, str], ...] = () # (etiqueta, valor) de detalle
|
||||||
|
como_corregir: str = ""
|
||||||
|
|
||||||
|
def a_dict(self) -> dict:
|
||||||
|
"""Forma serializable: se guarda en ``comprobantes.observaciones``."""
|
||||||
|
return {
|
||||||
|
"codigo": self.codigo,
|
||||||
|
"titulo": self.titulo,
|
||||||
|
"filas": [{"etiqueta": e, "valor": v} for e, v in self.filas],
|
||||||
|
"como_corregir": self.como_corregir,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Formato
|
||||||
|
# ============================================================================
|
||||||
|
def moneda(valor: float | int | None) -> str:
|
||||||
|
"""``1250`` → ``'S/ 1,250.00'``."""
|
||||||
|
return f"S/ {float(valor or 0):,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
def texto_observaciones(cantidad: int) -> str:
|
||||||
|
"""``1`` → ``'1 observación'``, ``2`` → ``'2 observaciones'``."""
|
||||||
|
return f"{cantidad} observación" if cantidad == 1 else f"{cantidad} observaciones"
|
||||||
|
|
||||||
|
|
||||||
|
def _nombre_guia(guia: dict) -> str:
|
||||||
|
return f"{guia.get('serie', '')}-{guia.get('numero', '')}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Motivos de rechazo de una factura (validación contra SAP)
|
||||||
|
# ============================================================================
|
||||||
|
def precio_no_coincide(numero_linea: int, importe_factura: float, importe_esperado: float,
|
||||||
|
guias: list[str], varias_lineas: bool) -> Observacion:
|
||||||
|
filas = []
|
||||||
|
if varias_lineas:
|
||||||
|
filas.append(("Línea", str(numero_linea)))
|
||||||
|
filas += [
|
||||||
|
("Guías", ", ".join(guias)),
|
||||||
|
("Importe en su factura", moneda(importe_factura)),
|
||||||
|
("Importe esperado", moneda(importe_esperado)),
|
||||||
|
]
|
||||||
|
return Observacion(
|
||||||
|
"precio_no_coincide", "El importe no coincide con nuestro registro", tuple(filas),
|
||||||
|
"Revise el importe acordado para este servicio y emita el comprobante con el valor correcto.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def guia_no_encontrada(guia: str) -> Observacion:
|
||||||
|
return Observacion(
|
||||||
|
"guia_no_encontrada", f"No encontramos la guía {guia}", (("Guía", guia),),
|
||||||
|
"Verifique que la guía esté registrada y que su serie y número estén bien escritos en la factura.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def linea_sin_guia(numero_linea: int, descripcion: str) -> Observacion:
|
||||||
|
filas = [("Línea", str(numero_linea))]
|
||||||
|
if descripcion:
|
||||||
|
filas.append(("Descripción", descripcion[:60]))
|
||||||
|
return Observacion(
|
||||||
|
"linea_sin_guia", "Una línea de la factura no indica su guía de remisión", tuple(filas),
|
||||||
|
"Indique la guía de remisión de cada servicio en la factura y envíela nuevamente.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cantidad_cero(numero_linea: int) -> Observacion:
|
||||||
|
return Observacion(
|
||||||
|
"cantidad_cero", "Una línea de la factura tiene cantidad cero", (("Línea", str(numero_linea)),),
|
||||||
|
"Corrija la cantidad de la línea y emita el comprobante nuevamente.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Motivos de rechazo de un correo sin comprobante válido
|
||||||
|
# ============================================================================
|
||||||
|
def sin_pdf_xml(archivos_recibidos: int) -> Observacion:
|
||||||
|
return Observacion(
|
||||||
|
"sin_pdf_xml", "No recibimos el XML ni el PDF de su comprobante",
|
||||||
|
(("Archivos recibidos", str(archivos_recibidos)),),
|
||||||
|
"Adjunte el XML y el PDF de la factura electrónica en el mismo correo.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def falta_xml(pdfs_recibidos: int) -> Observacion:
|
||||||
|
return Observacion(
|
||||||
|
"falta_xml", "Falta el archivo XML de su comprobante", (("PDF recibidos", str(pdfs_recibidos)),),
|
||||||
|
"Adjunte también el XML de la factura electrónica: el PDF por sí solo no es suficiente.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sin_factura(documentos_recibidos: list[str]) -> Observacion:
|
||||||
|
return Observacion(
|
||||||
|
"sin_factura", "No encontramos una factura electrónica en su correo",
|
||||||
|
(("Documentos recibidos", ", ".join(documentos_recibidos) or "ninguno reconocible"),),
|
||||||
|
"Envíe el XML y el PDF de la factura electrónica. Los demás documentos no se registran por este medio.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Resultado de la validación de precios → observaciones
|
||||||
|
# ============================================================================
|
||||||
|
def _sin_datos_sap(item: dict) -> bool:
|
||||||
|
"""La guía no devolvió ningún dato de SAP (y no fue por un error de consulta)."""
|
||||||
|
return item.get("line_total") == 0 and item.get("quantity") == 0 and not item.get("error")
|
||||||
|
|
||||||
|
|
||||||
|
def desde_validacion(factura: dict, resultados_val: list[dict]) -> list[Observacion]:
|
||||||
|
"""
|
||||||
|
Observaciones de una factura a partir de los resultados de ``validate_prices``
|
||||||
|
(uno por línea). Una línea que no coincide da una observación por cada guía
|
||||||
|
que no se encontró y, si hay datos de SAP para alguna otra, una de importe.
|
||||||
|
"""
|
||||||
|
lineas = factura.get("lineas", [])
|
||||||
|
varias_lineas = len(lineas) > 1
|
||||||
|
observaciones: list[Observacion] = []
|
||||||
|
|
||||||
|
for i, resultado in enumerate(resultados_val):
|
||||||
|
if resultado.get("ok"):
|
||||||
|
continue
|
||||||
|
numero = i + 1
|
||||||
|
linea = lineas[i] if i < len(lineas) else {}
|
||||||
|
etiqueta = resultado.get("label", "")
|
||||||
|
|
||||||
|
if etiqueta == "Cantidad cero, sin validar":
|
||||||
|
observaciones.append(cantidad_cero(numero))
|
||||||
|
elif etiqueta == "Sin guía de referencia":
|
||||||
|
observaciones.append(linea_sin_guia(numero, linea.get("descripcion", "")))
|
||||||
|
else:
|
||||||
|
guias_lt = resultado.get("guias_lt", [])
|
||||||
|
no_encontradas = [it for it in guias_lt if _sin_datos_sap(it)]
|
||||||
|
observaciones += [guia_no_encontrada(_nombre_guia(it["guia"])) for it in no_encontradas]
|
||||||
|
if len(no_encontradas) < len(guias_lt):
|
||||||
|
observaciones.append(precio_no_coincide(
|
||||||
|
numero, float(linea.get("total_linea") or 0), float(resultado.get("sap_price") or 0),
|
||||||
|
[_nombre_guia(it["guia"]) for it in guias_lt], varias_lineas,
|
||||||
|
))
|
||||||
|
return observaciones
|
||||||
+29
-4
@@ -52,11 +52,30 @@ def get_access_token() -> str:
|
|||||||
correlation_id = result.get("correlation_id")
|
correlation_id = result.get("correlation_id")
|
||||||
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {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]:
|
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.
|
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 = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
@@ -73,7 +92,7 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
|
|||||||
|
|
||||||
params = {
|
params = {
|
||||||
"$filter": filter_query,
|
"$filter": filter_query,
|
||||||
"$select": "id,receivedDateTime,hasAttachments,subject,from",
|
"$select": "id,receivedDateTime,hasAttachments,subject,from,internetMessageHeaders",
|
||||||
"$top": 50
|
"$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"),
|
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"sender": sender,
|
"sender": sender,
|
||||||
"subject": msg.get("subject", ""),
|
"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")
|
url = data.get("@odata.nextLink")
|
||||||
@@ -153,7 +173,7 @@ def download_attachments(user_email: str, message_id: str, token: str, download_
|
|||||||
return downloaded_files
|
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.
|
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 html_body: Contenido del correo en formato HTML.
|
||||||
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
|
: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 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.
|
:return: True si se envió correctamente, False en caso contrario.
|
||||||
"""
|
"""
|
||||||
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
|
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
|
# Construir lista de adjuntos
|
||||||
attachments_payload = []
|
attachments_payload = []
|
||||||
if attachment_paths:
|
if attachment_paths:
|
||||||
@@ -229,6 +253,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
|||||||
},
|
},
|
||||||
"toRecipients": recipients_payload,
|
"toRecipients": recipients_payload,
|
||||||
"ccRecipients": cc_payload,
|
"ccRecipients": cc_payload,
|
||||||
|
"bccRecipients": bcc_payload,
|
||||||
"attachments": attachments_payload
|
"attachments": attachments_payload
|
||||||
},
|
},
|
||||||
"saveToSentItems": "true"
|
"saveToSentItems": "true"
|
||||||
|
|||||||
+94
-22
@@ -20,6 +20,8 @@ import documentos as docs
|
|||||||
import persistencia
|
import persistencia
|
||||||
import draft_sap
|
import draft_sap
|
||||||
import notificaciones
|
import notificaciones
|
||||||
|
import observaciones
|
||||||
|
import respuesta_proveedor
|
||||||
import sapb1_manager
|
import sapb1_manager
|
||||||
import seguimiento
|
import seguimiento
|
||||||
from pipeline_utils import log
|
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)
|
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
|
# 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
|
# 3. Descargar adjuntos
|
||||||
lista_adjuntos = adjuntos.descargar_adjuntos(email, email_addr, token, download_folder)
|
lista_adjuntos = adjuntos.descargar_adjuntos(email, email_addr, token, download_folder)
|
||||||
if not lista_adjuntos:
|
if not lista_adjuntos:
|
||||||
log("⏭️ Correo sin adjuntos")
|
log("⏭️ Correo sin adjuntos") # no se responde: no hubo intento de enviar un comprobante
|
||||||
return
|
return
|
||||||
|
n_recibidos = len(lista_adjuntos)
|
||||||
|
|
||||||
# 4. Descomprimir ZIPs
|
# 4. Descomprimir ZIPs
|
||||||
email_utilities.descomprimir_adjuntos([email], download_folder)
|
email_utilities.descomprimir_adjuntos([email], download_folder)
|
||||||
|
|
||||||
# 5. Filtrar PDF/XML y limpiar si no hay válidos
|
# 5. Filtrar PDF/XML y limpiar si no hay válidos
|
||||||
if not adjuntos.filtrar_y_limpiar(email, download_folder):
|
if not adjuntos.filtrar_y_limpiar(email, download_folder):
|
||||||
|
_rechazar_correo(email, token, email_addr, [observaciones.sin_pdf_xml(n_recibidos)])
|
||||||
return
|
return
|
||||||
|
|
||||||
# 6. Procesar PDFs
|
# 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)
|
documentos = docs.procesar_xmls(email, download_folder, pdf_cache)
|
||||||
if not documentos:
|
if not documentos:
|
||||||
log("⏭️ No se extrajeron documentos XML")
|
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
|
return
|
||||||
|
|
||||||
# 8. Completar guías faltantes desde PDF
|
# 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"]
|
facturas = [d for d in documentos if d.get("tipo") == "01"]
|
||||||
if not facturas:
|
if not facturas:
|
||||||
log("⏭️ No hay facturas (tipo 01) para SAP")
|
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
|
return
|
||||||
|
|
||||||
for factura in facturas:
|
for factura in facturas:
|
||||||
@@ -190,6 +228,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
|||||||
log(f"📄 Factura: {serie}-{numero}")
|
log(f"📄 Factura: {serie}-{numero}")
|
||||||
registro = _registrar_comprobante(email, factura)
|
registro = _registrar_comprobante(email, factura)
|
||||||
|
|
||||||
|
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
|
||||||
|
for p in email.get("resultado_paths", []):
|
||||||
|
if p and os.path.exists(p) and p not in adjuntos_email:
|
||||||
|
adjuntos_email.append(p)
|
||||||
|
|
||||||
|
estado = None # aceptado | rechazado | error
|
||||||
|
detalle_error = None
|
||||||
|
lista_obs: list = []
|
||||||
|
resultados_val: list = []
|
||||||
|
draft: dict = {}
|
||||||
|
validation_table = valor_referencial_table = ""
|
||||||
|
|
||||||
|
try:
|
||||||
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
|
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
|
||||||
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
|
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
|
||||||
validation_table = notificaciones.build_validation_html(resultados_val)
|
validation_table = notificaciones.build_validation_html(resultados_val)
|
||||||
@@ -201,44 +252,65 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
|
|||||||
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
|
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
|
||||||
draft = draft_sap.build_sap_draft(factura, resultados_val)
|
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)
|
# 14. Guardar draft JSON (siempre, para depuración)
|
||||||
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
|
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
|
||||||
|
|
||||||
# 15. Crear draft en SAP o generar HTML
|
detalle_error = draft_sap.hay_error_de_consulta(resultados_val)
|
||||||
if todas_coinciden:
|
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:
|
try:
|
||||||
sap_result = sapb1_manager.create_draft(draft)
|
sap_result = sapb1_manager.create_draft(draft)
|
||||||
doc_entry = sap_result.get("DocEntry")
|
doc_entry = sap_result.get("DocEntry")
|
||||||
|
estado = "aceptado"
|
||||||
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
|
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
|
||||||
_registrar_resultado(registro, "aceptado", doc_entry=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)
|
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
estado, detalle_error = "error", str(ex)
|
||||||
log(f"❌ Error creando draft {serie}-{numero}: {ex}", severity=logging.ERROR)
|
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:
|
else:
|
||||||
|
estado = "rechazado"
|
||||||
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
|
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)
|
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 estado == "error":
|
||||||
if token:
|
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
|
||||||
destinatario = email.get("sender")
|
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
|
||||||
if destinatario and destinatario != "Desconocido":
|
|
||||||
outlook_utilities.send_email(
|
# 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
|
||||||
token, email_addr,
|
if not token:
|
||||||
[destinatario],
|
continue
|
||||||
f"Draft SAP B1 - {serie}-{numero}",
|
respuesta_proveedor.enviar_a_equipo(
|
||||||
html_body, adjuntos_email,
|
token, email_addr, f"Draft SAP B1 - {serie}-{numero}",
|
||||||
cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
|
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:
|
else:
|
||||||
log("⚠️ No se pudo determinar el remitente, notificación no enviada", severity=logging.WARNING)
|
respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""
|
||||||
|
Respuesta al proveedor: arma y envía el correo de aceptación o rechazo (plantillas
|
||||||
|
``plantilla/plantilla-aceptado.html`` y ``plantilla-rechazado.html``) a quien envió
|
||||||
|
el correo con el XML y el PDF.
|
||||||
|
|
||||||
|
Dos modos, según ``NOTIFICACION_MODO`` en el .env:
|
||||||
|
|
||||||
|
prueba (por defecto) la respuesta NO sale al proveedor: va al equipo
|
||||||
|
(``NOTIFICACION_EQUIPO``) con el asunto "[PRUEBA → remitente]".
|
||||||
|
real la respuesta va al remitente, con copia oculta al equipo.
|
||||||
|
|
||||||
|
Como el sistema contesta solo y a terceros, **no responde** a: remitentes
|
||||||
|
desconocidos o sin dominio válido, el propio buzón, direcciones ``no-reply`` y
|
||||||
|
afines, mensajes automáticos (respuestas automáticas, rebotes) y, en correos sin
|
||||||
|
comprobante, a respuestas (``Re:``) o mensajes que citan un código nuestro. Además
|
||||||
|
limita a ``MAX_RESPUESTAS_CORREO`` por remitente cada 24 h las respuestas a correos
|
||||||
|
sin comprobante.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config_validator import MODOS_NOTIFICACION as MODOS
|
||||||
|
import observaciones as obs
|
||||||
|
import outlook_utilities
|
||||||
|
import plantillas
|
||||||
|
import seguimiento
|
||||||
|
from pipeline_utils import log
|
||||||
|
|
||||||
|
EQUIPO_POR_DEFECTO = ["jlopez@aberio.com.pe", "gcalle@pucp.pe"]
|
||||||
|
MAX_RESPUESTAS_CORREO = 3
|
||||||
|
|
||||||
|
_DIRECCION = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
_AUTOMATICA = re.compile(r"no[-_.]?reply|do[-_.]?not[-_.]?reply|no[-_.]?responder|mailer-daemon|postmaster|bounce", re.I)
|
||||||
|
_CODIGO = re.compile(r"ABR-\d{6}-\d{5}")
|
||||||
|
_RESPUESTA = re.compile(r"^\s*(re|rv)\s*:", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Configuración (.env)
|
||||||
|
# ============================================================================
|
||||||
|
def modo() -> str:
|
||||||
|
"""``prueba`` o ``real``. Cualquier otro valor (o ninguno) es ``prueba``."""
|
||||||
|
valor = os.getenv("NOTIFICACION_MODO", "prueba").strip().lower()
|
||||||
|
return valor if valor in MODOS else "prueba"
|
||||||
|
|
||||||
|
|
||||||
|
def equipo() -> list[str]:
|
||||||
|
"""Direcciones internas (``NOTIFICACION_EQUIPO``, separadas por coma o punto y coma)."""
|
||||||
|
lista = [d.strip() for d in re.split(r"[;,]", os.getenv("NOTIFICACION_EQUIPO", "")) if d.strip()]
|
||||||
|
return lista or list(EQUIPO_POR_DEFECTO)
|
||||||
|
|
||||||
|
|
||||||
|
def contacto_correo() -> str:
|
||||||
|
return os.getenv("NOTIFICACION_CONTACTO", "").strip() or os.getenv("EMAIL", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def envio_correo() -> str:
|
||||||
|
"""Buzón al que el proveedor debe reenviar el comprobante corregido."""
|
||||||
|
return os.getenv("EMAIL", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def logo_url() -> str:
|
||||||
|
return os.getenv("LOGO_URL", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Protecciones
|
||||||
|
# ============================================================================
|
||||||
|
def motivo_omision(email: dict, buzon: str, sin_comprobante: bool = False) -> str | None:
|
||||||
|
"""
|
||||||
|
Motivo por el que NO se debe responder a este correo, o None si se puede.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Dict del correo (``sender``, ``subject``, ``es_automatico``).
|
||||||
|
buzon: Dirección del buzón del sistema (``EMAIL``).
|
||||||
|
sin_comprobante: True si la respuesta es un rechazo de un correo sin comprobante válido.
|
||||||
|
"""
|
||||||
|
remitente = (email.get("sender") or "").strip()
|
||||||
|
if not remitente or remitente == "Desconocido":
|
||||||
|
return "remitente desconocido"
|
||||||
|
if not _DIRECCION.match(remitente):
|
||||||
|
return f"dirección no válida ({remitente})"
|
||||||
|
if remitente.lower() == buzon.strip().lower():
|
||||||
|
return "el remitente es el propio buzón"
|
||||||
|
if _AUTOMATICA.search(remitente.split("@")[0]):
|
||||||
|
return "dirección automática (no-reply)"
|
||||||
|
if email.get("es_automatico"):
|
||||||
|
return "mensaje automático (respuesta automática o rebote)"
|
||||||
|
if sin_comprobante:
|
||||||
|
asunto = email.get("subject") or ""
|
||||||
|
if _RESPUESTA.match(asunto) or _CODIGO.search(asunto):
|
||||||
|
return "parece una respuesta a una notificación nuestra"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _destinatarios(remitente: str) -> tuple[list[str], list[str], str]:
|
||||||
|
"""(para, copia oculta, prefijo del asunto) según el modo."""
|
||||||
|
if modo() == "real":
|
||||||
|
return [remitente], equipo(), ""
|
||||||
|
return equipo(), [], f"[PRUEBA → {remitente}] "
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Contexto de las plantillas
|
||||||
|
# ============================================================================
|
||||||
|
def _fecha_hora(email: dict) -> tuple[str, str]:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(email["fecha"], "%Y-%m-%d %H:%M:%S")
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
dt = datetime.now()
|
||||||
|
return f"{dt:%d/%m/%Y}", f"{dt:%H:%M}"
|
||||||
|
|
||||||
|
|
||||||
|
def _comun(email: dict, registro: dict) -> dict:
|
||||||
|
fecha, hora = _fecha_hora(email)
|
||||||
|
return {
|
||||||
|
"logo_url": logo_url(),
|
||||||
|
"recepcion_fecha": fecha,
|
||||||
|
"recepcion_hora": hora,
|
||||||
|
"codigo_seguimiento": registro["codigo_seguimiento"],
|
||||||
|
"contacto_correo": contacto_correo(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _comprobante(factura: dict) -> dict:
|
||||||
|
numero = f"{factura.get('serie', '')}-{factura.get('numero', '')}"
|
||||||
|
tipo = "Factura electrónica" if factura.get("tipo") == "01" else factura.get("tipo_descripcion", "Comprobante")
|
||||||
|
return {
|
||||||
|
"comprobante_tipo_corto": "factura",
|
||||||
|
"comprobante_tipo": tipo,
|
||||||
|
"comprobante_numero": numero,
|
||||||
|
"proveedor_razon_social": factura.get("razon_social_emisor") or "—",
|
||||||
|
"proveedor_ruc": factura.get("ruc_emisor") or None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def guias_para_plantilla(factura: dict, resultados_val: list[dict]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Filas de la tabla "Guías revisadas". La validación es por línea, así que todas las
|
||||||
|
guías de una línea llevan el mismo resultado; el importe de la línea se muestra una
|
||||||
|
vez (en su primera guía) y las demás dicen que están incluidas en la línea.
|
||||||
|
"""
|
||||||
|
filas = []
|
||||||
|
for i, linea in enumerate(factura.get("lineas", [])):
|
||||||
|
resultado = resultados_val[i] if i < len(resultados_val) else {}
|
||||||
|
guias = [it["guia"] for it in resultado.get("guias_lt", [])] or linea.get("guias_remision") or []
|
||||||
|
for j, guia in enumerate(guias):
|
||||||
|
filas.append({
|
||||||
|
"guia": f"{guia.get('serie', '')}-{guia.get('numero', '')}",
|
||||||
|
"importe_factura": obs.moneda(linea.get("total_linea")) if j == 0 else "incluida en la línea",
|
||||||
|
"conforme": bool(resultado.get("ok")),
|
||||||
|
})
|
||||||
|
return filas
|
||||||
|
|
||||||
|
|
||||||
|
def _fecha_estimada_pago(draft: dict) -> str | None:
|
||||||
|
"""Fecha de vencimiento del draft, solo si es posterior a la de contabilización (hay condición de pago)."""
|
||||||
|
try:
|
||||||
|
vencimiento = datetime.strptime(draft["DocDueDate"], "%Y-%m-%d")
|
||||||
|
contabilizacion = datetime.strptime(draft["DocDate"], "%Y-%m-%d")
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
return f"{vencimiento:%d/%m/%Y}" if vencimiento > contabilizacion else None
|
||||||
|
|
||||||
|
|
||||||
|
def contexto_aceptado(email: dict, registro: dict, factura: dict, resultados_val: list[dict], draft: dict) -> dict:
|
||||||
|
comprobante = _comprobante(factura)
|
||||||
|
numero = comprobante["comprobante_numero"]
|
||||||
|
return {
|
||||||
|
**_comun(email, registro), **comprobante,
|
||||||
|
"asunto": f"Aceptado · Factura {numero} · {registro['codigo_seguimiento']}",
|
||||||
|
"preheader": f"Su factura {numero} pasó todas las validaciones. No necesita hacer nada más.",
|
||||||
|
"fecha_estimada_pago": _fecha_estimada_pago(draft),
|
||||||
|
"guias": guias_para_plantilla(factura, resultados_val),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _bloque_observaciones(observaciones: list[obs.Observacion]) -> dict:
|
||||||
|
texto = obs.texto_observaciones(len(observaciones))
|
||||||
|
return {
|
||||||
|
"observaciones_texto": texto,
|
||||||
|
"preheader": f"Tiene {texto}. Corríjalas y envíenos el comprobante nuevamente.",
|
||||||
|
"observaciones": [
|
||||||
|
{"numero": n, "titulo": o.titulo, "como_corregir": o.como_corregir,
|
||||||
|
"filas": [{"etiqueta": e, "valor": v} for e, v in o.filas]}
|
||||||
|
for n, o in enumerate(observaciones, start=1)
|
||||||
|
],
|
||||||
|
"envio_correo": envio_correo(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def contexto_rechazo_factura(email: dict, registro: dict, factura: dict, resultados_val: list[dict],
|
||||||
|
observaciones: list[obs.Observacion]) -> dict:
|
||||||
|
comprobante = _comprobante(factura)
|
||||||
|
guias = guias_para_plantilla(factura, resultados_val)
|
||||||
|
return {
|
||||||
|
**_comun(email, registro), **comprobante, **_bloque_observaciones(observaciones),
|
||||||
|
"asunto": f"Rechazado · Factura {comprobante['comprobante_numero']} · {registro['codigo_seguimiento']}",
|
||||||
|
"guias": guias, "tiene_guias": bool(guias),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def contexto_rechazo_correo(email: dict, registro: dict, observaciones: list[obs.Observacion]) -> dict:
|
||||||
|
"""Rechazo de un correo sin comprobante identificable: no hay proveedor, número ni guías que mostrar."""
|
||||||
|
return {
|
||||||
|
**_comun(email, registro), **_bloque_observaciones(observaciones),
|
||||||
|
"asunto": f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}",
|
||||||
|
"comprobante_tipo_corto": "comprobante", "comprobante_tipo": "", "comprobante_numero": None,
|
||||||
|
"proveedor_razon_social": email.get("sender") or "—", "proveedor_ruc": None,
|
||||||
|
"guias": [], "tiene_guias": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Envío
|
||||||
|
# ============================================================================
|
||||||
|
def _anotar(codigo: str, estado: str, error: str | None = None, destinatario: str | None = None) -> None:
|
||||||
|
try:
|
||||||
|
seguimiento.registrar_notificacion(codigo, estado, error=error, destinatario=destinatario)
|
||||||
|
except Exception as ex:
|
||||||
|
log(f"⚠️ No se pudo registrar la notificación en seguimiento: {ex}", severity=logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def responder(token: str, buzon: str, email: dict, plantilla: str, contexto: dict, registro: dict,
|
||||||
|
sin_comprobante: bool = False) -> str:
|
||||||
|
"""
|
||||||
|
Responde al remitente con la plantilla ``plantilla`` ("aceptado" o "rechazado").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``"enviada"``, ``"fallida"`` u ``"omitida"``; también queda anotado en ``comprobantes``.
|
||||||
|
"""
|
||||||
|
codigo = registro["codigo_seguimiento"]
|
||||||
|
remitente = email.get("sender") or ""
|
||||||
|
|
||||||
|
motivo = motivo_omision(email, buzon, sin_comprobante)
|
||||||
|
if motivo is None and sin_comprobante and seguimiento.contar_respuestas_correo(remitente) >= MAX_RESPUESTAS_CORREO:
|
||||||
|
motivo = f"ya se respondió {MAX_RESPUESTAS_CORREO} veces a este remitente en 24 h"
|
||||||
|
if motivo:
|
||||||
|
log(f"⏭️ No se responde al proveedor: {motivo}", severity=logging.WARNING)
|
||||||
|
_anotar(codigo, "omitida", motivo)
|
||||||
|
return "omitida"
|
||||||
|
|
||||||
|
para, copia_oculta, prefijo = _destinatarios(remitente)
|
||||||
|
try:
|
||||||
|
html = plantillas.renderizar_plantilla(plantilla, contexto)
|
||||||
|
except plantillas.PlantillaError as ex:
|
||||||
|
log(f"❌ No se pudo armar la respuesta ({codigo}): {ex}", severity=logging.ERROR)
|
||||||
|
_anotar(codigo, "fallida", f"Plantilla: {ex}")
|
||||||
|
return "fallida"
|
||||||
|
|
||||||
|
enviado = outlook_utilities.send_email(
|
||||||
|
token, buzon, para, prefijo + contexto["asunto"], html, None, bcc_recipients=copia_oculta or None,
|
||||||
|
)
|
||||||
|
estado = "enviada" if enviado else "fallida"
|
||||||
|
log(f"{'✉️ ' if enviado else '❌'} Respuesta {plantilla} {estado} → {', '.join(para)} (modo {modo()})",
|
||||||
|
severity=logging.INFO if enviado else logging.ERROR)
|
||||||
|
_anotar(codigo, estado, None if enviado else "Microsoft Graph rechazó el envío (ver log)", ", ".join(para))
|
||||||
|
return estado
|
||||||
|
|
||||||
|
|
||||||
|
def omitir_respuesta(registro: dict, motivo: str) -> None:
|
||||||
|
"""Deja anotado que a este comprobante no se le respondió, y por qué."""
|
||||||
|
_anotar(registro["codigo_seguimiento"], "omitida", motivo)
|
||||||
|
|
||||||
|
|
||||||
|
def encabezado_tecnico(email: dict, registro: dict | None) -> str:
|
||||||
|
"""Datos de contexto que se anteponen al correo técnico que recibe el equipo."""
|
||||||
|
codigo = registro["codigo_seguimiento"] if registro else "(sin código: falló el seguimiento)"
|
||||||
|
return (
|
||||||
|
f"<p><b>Remitente:</b> {email.get('sender', '')}<br>"
|
||||||
|
f"<b>Asunto:</b> {email.get('subject', '')}<br>"
|
||||||
|
f"<b>Código de seguimiento:</b> {codigo}<br>"
|
||||||
|
f"<b>Modo de notificación:</b> {modo()}</p>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def enviar_a_equipo(token: str, buzon: str, asunto: str, html: str, adjuntos: list[str] | None = None) -> bool:
|
||||||
|
"""Envía el correo técnico (tablas de SAP, JSON adjuntos…) solo al equipo interno."""
|
||||||
|
destinos = equipo()
|
||||||
|
if not destinos:
|
||||||
|
return False
|
||||||
|
return outlook_utilities.send_email(token, buzon, destinos, asunto, html, adjuntos)
|
||||||
+23
-4
@@ -18,7 +18,7 @@ El esquema se crea en ``database_utilities.init_db``.
|
|||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import database_utilities
|
import database_utilities
|
||||||
|
|
||||||
@@ -159,7 +159,8 @@ def actualizar_resultado(codigo_seguimiento: str, estado: str, observaciones: li
|
|||||||
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
||||||
|
|
||||||
|
|
||||||
def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None) -> None:
|
def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None,
|
||||||
|
destinatario: str | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
Guarda el estado de la notificación enviada al proveedor.
|
Guarda el estado de la notificación enviada al proveedor.
|
||||||
|
|
||||||
@@ -167,6 +168,7 @@ def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | No
|
|||||||
codigo_seguimiento: Código del registro.
|
codigo_seguimiento: Código del registro.
|
||||||
estado: Uno de ``ESTADOS_NOTIFICACION``.
|
estado: Uno de ``ESTADOS_NOTIFICACION``.
|
||||||
error: Motivo, si la notificación falló o se omitió.
|
error: Motivo, si la notificación falló o se omitió.
|
||||||
|
destinatario: Direcciones a las que se envió realmente (en modo prueba no son las del remitente).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Si ``estado`` no es válido.
|
ValueError: Si ``estado`` no es válido.
|
||||||
@@ -179,9 +181,26 @@ def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | No
|
|||||||
with _conexion() as conn:
|
with _conexion() as conn:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""UPDATE comprobantes
|
"""UPDATE comprobantes
|
||||||
SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?, actualizado_en = ?
|
SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?,
|
||||||
|
notificacion_destinatario = ?, actualizado_en = ?
|
||||||
WHERE codigo_seguimiento = ?""",
|
WHERE codigo_seguimiento = ?""",
|
||||||
(estado, error, ahora, ahora, codigo_seguimiento),
|
(estado, error, ahora, destinatario, ahora, codigo_seguimiento),
|
||||||
)
|
)
|
||||||
if cursor.rowcount == 0:
|
if cursor.rowcount == 0:
|
||||||
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
||||||
|
|
||||||
|
|
||||||
|
def contar_respuestas_correo(remitente: str, horas: int = 24) -> int:
|
||||||
|
"""
|
||||||
|
Cuenta las respuestas ya enviadas en las últimas ``horas`` a un remitente por
|
||||||
|
correos **sin comprobante** (sin serie ni número). Sirve de freno contra bucles
|
||||||
|
y contra remitentes que insisten con correos inválidos.
|
||||||
|
"""
|
||||||
|
desde = (datetime.now() - timedelta(hours=horas)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
with _conexion() as conn:
|
||||||
|
return conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM comprobantes c JOIN emails e ON e.id = c.email_id
|
||||||
|
WHERE c.serie IS NULL AND c.notificacion_estado = 'enviada'
|
||||||
|
AND c.notificacion_fecha >= ? AND lower(e.sender) = lower(?)""",
|
||||||
|
(desde, remitente),
|
||||||
|
).fetchone()[0]
|
||||||
@@ -23,3 +23,14 @@ def base_de_datos_temporal(tmp_path_factory, monkeypatch):
|
|||||||
"""
|
"""
|
||||||
monkeypatch.setattr(database_utilities, "DB_NAME", str(tmp_path_factory.mktemp("bd") / "emails_prueba.db"))
|
monkeypatch.setattr(database_utilities, "DB_NAME", str(tmp_path_factory.mktemp("bd") / "emails_prueba.db"))
|
||||||
database_utilities.init_db()
|
database_utilities.init_db()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def entorno_de_notificacion_aislado(monkeypatch):
|
||||||
|
"""
|
||||||
|
Los módulos cargan el ``.env`` real al importarse. Las pruebas no deben depender de él:
|
||||||
|
sin esto, un ``NOTIFICACION_MODO=real`` del .env haría que se probara (y enviara) en modo real.
|
||||||
|
"""
|
||||||
|
for variable in ("NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "NOTIFICACION_CONTACTO", "LOGO_URL"):
|
||||||
|
monkeypatch.delenv(variable, raising=False)
|
||||||
|
monkeypatch.setenv("EMAIL", "buzon@aberio.com")
|
||||||
@@ -5,7 +5,8 @@ import pytest
|
|||||||
import config_validator as cv
|
import config_validator as cv
|
||||||
|
|
||||||
OBLIGATORIAS = [v for grupo in cv.OBLIGATORIAS.values() for v in grupo]
|
OBLIGATORIAS = [v for grupo in cv.OBLIGATORIAS.values() for v in grupo]
|
||||||
OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", *cv.VERIFICACION_SSL]
|
OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", "NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "LOGO_URL",
|
||||||
|
*cv.VERIFICACION_SSL]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -74,3 +75,32 @@ def test_verificar_termina_con_codigo_1_si_hay_errores(env_completo, monkeypatch
|
|||||||
|
|
||||||
def test_verificar_no_termina_si_todo_esta_bien(env_completo):
|
def test_verificar_no_termina_si_todo_esta_bien(env_completo):
|
||||||
cv.verificar_configuracion() # no debe lanzar SystemExit
|
cv.verificar_configuracion() # no debe lanzar SystemExit
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_modo_de_notificacion_se_anuncia_al_arrancar(env_completo, monkeypatch):
|
||||||
|
_, advertencias = cv.validar_configuracion()
|
||||||
|
assert any("PRUEBA" in a for a in advertencias) and not any("REAL" in a for a in advertencias)
|
||||||
|
|
||||||
|
monkeypatch.setenv("NOTIFICACION_MODO", "Real")
|
||||||
|
_, advertencias = cv.validar_configuracion()
|
||||||
|
assert any("REAL" in a and "quien envía el correo" in a for a in advertencias)
|
||||||
|
assert not any("PRUEBA" in a for a in advertencias)
|
||||||
|
|
||||||
|
|
||||||
|
def test_modo_de_notificacion_invalido_cae_en_prueba(env_completo, monkeypatch):
|
||||||
|
monkeypatch.setenv("NOTIFICACION_MODO", "produccion")
|
||||||
|
errores, advertencias = cv.validar_configuracion()
|
||||||
|
assert errores == []
|
||||||
|
assert any("produccion" in a and "se usará prueba" in a for a in advertencias)
|
||||||
|
|
||||||
|
|
||||||
|
def test_equipo_y_logo_sin_definir_solo_advierten(env_completo, monkeypatch):
|
||||||
|
errores, advertencias = cv.validar_configuracion()
|
||||||
|
assert errores == []
|
||||||
|
assert any("NOTIFICACION_EQUIPO" in a for a in advertencias) and any("LOGO_URL" in a for a in advertencias)
|
||||||
|
|
||||||
|
monkeypatch.setenv("NOTIFICACION_EQUIPO", "a@x.com")
|
||||||
|
monkeypatch.setenv("LOGO_URL", "https://x.com/logo.png")
|
||||||
|
_, advertencias = cv.validar_configuracion()
|
||||||
|
assert not any("NOTIFICACION_EQUIPO" in a or "LOGO_URL" in a for a in advertencias)
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import observaciones as obs
|
||||||
|
|
||||||
|
|
||||||
|
def _item(numero, line_total=100.0, quantity=10.0, error=None):
|
||||||
|
item = {"guia": {"tipo": "09", "serie": "T001", "numero": numero}, "line_total": line_total, "quantity": quantity}
|
||||||
|
if error:
|
||||||
|
item["error"] = error
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _resultado(ok, guias, sap_price=100.0, label="LineTotal (FL*) vs total_linea"):
|
||||||
|
return {"ok": ok, "guias_lt": guias, "sap_price": sap_price, "label": label}
|
||||||
|
|
||||||
|
|
||||||
|
FACTURA = {"lineas": [{"total_linea": 624.38, "descripcion": "Servicio A"}]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_formato():
|
||||||
|
assert obs.moneda(1250) == "S/ 1,250.00"
|
||||||
|
assert obs.moneda(None) == "S/ 0.00"
|
||||||
|
assert obs.texto_observaciones(1) == "1 observación"
|
||||||
|
assert obs.texto_observaciones(3) == "3 observaciones"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lineas_conformes_no_generan_observaciones():
|
||||||
|
assert obs.desde_validacion(FACTURA, [_resultado(True, [_item("1")])]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_importe_distinto_muestra_ambos_importes():
|
||||||
|
(o,) = obs.desde_validacion(FACTURA, [_resultado(False, [_item("1"), _item("2")], sap_price=674.38)])
|
||||||
|
|
||||||
|
assert o.codigo == "precio_no_coincide"
|
||||||
|
assert dict(o.filas) == {"Guías": "T001-1, T001-2", "Importe en su factura": "S/ 624.38", "Importe esperado": "S/ 674.38"}
|
||||||
|
assert "Línea" not in dict(o.filas) # con una sola línea no hace falta numerarla
|
||||||
|
|
||||||
|
|
||||||
|
def test_con_varias_lineas_se_indica_cual():
|
||||||
|
factura = {"lineas": [{"total_linea": 10}, {"total_linea": 20}]}
|
||||||
|
resultados = [_resultado(True, [_item("1")]), _resultado(False, [_item("2")], sap_price=25.0)]
|
||||||
|
|
||||||
|
(o,) = obs.desde_validacion(factura, resultados)
|
||||||
|
|
||||||
|
assert dict(o.filas)["Línea"] == "2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_guia_sin_datos_en_sap_es_guia_no_encontrada_y_no_importe():
|
||||||
|
guias = [_item("1", 0.0, 0.0), _item("2", 0.0, 0.0)]
|
||||||
|
observaciones = obs.desde_validacion(FACTURA, [_resultado(False, guias, 0.0, "Sin guías SAP")])
|
||||||
|
|
||||||
|
assert [o.codigo for o in observaciones] == ["guia_no_encontrada", "guia_no_encontrada"]
|
||||||
|
assert [o.titulo for o in observaciones] == ["No encontramos la guía T001-1", "No encontramos la guía T001-2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_guia_encontrada_y_otra_no_da_ambas_observaciones():
|
||||||
|
guias = [_item("1", 0.0, 0.0), _item("2", 40.0, 5.0)]
|
||||||
|
observaciones = obs.desde_validacion(FACTURA, [_resultado(False, guias, sap_price=40.0)])
|
||||||
|
|
||||||
|
assert [o.codigo for o in observaciones] == ["guia_no_encontrada", "precio_no_coincide"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_guia_con_error_de_consulta_no_se_reporta_como_no_encontrada():
|
||||||
|
guias = [_item("1", 0.0, 0.0, error="timeout")]
|
||||||
|
assert obs.desde_validacion(FACTURA, [_resultado(False, guias, 0.0)]) == [
|
||||||
|
obs.precio_no_coincide(1, 624.38, 0.0, ["T001-1"], False)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lineas_sin_guia_o_con_cantidad_cero():
|
||||||
|
factura = {"lineas": [{"descripcion": "Flete a Lima", "total_linea": 5}, {"total_linea": 7}]}
|
||||||
|
resultados = [_resultado(False, [], label="Sin guía de referencia"), _resultado(False, [], label="Cantidad cero, sin validar")]
|
||||||
|
|
||||||
|
sin_guia, cero = obs.desde_validacion(factura, resultados)
|
||||||
|
|
||||||
|
assert sin_guia.codigo == "linea_sin_guia" and dict(sin_guia.filas) == {"Línea": "1", "Descripción": "Flete a Lima"}
|
||||||
|
assert cero.codigo == "cantidad_cero" and dict(cero.filas) == {"Línea": "2"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_motivos_de_correo_sin_comprobante():
|
||||||
|
assert obs.sin_pdf_xml(2).filas == (("Archivos recibidos", "2"),)
|
||||||
|
assert obs.falta_xml(1).codigo == "falta_xml"
|
||||||
|
assert obs.sin_factura(["Guía de Remisión"]).filas == (("Documentos recibidos", "Guía de Remisión"),)
|
||||||
|
assert obs.sin_factura([]).filas == (("Documentos recibidos", "ninguno reconocible"),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_todas_las_observaciones_tienen_titulo_y_como_corregir():
|
||||||
|
muestras = [
|
||||||
|
obs.precio_no_coincide(1, 1, 2, ["T001-1"], True), obs.guia_no_encontrada("T001-1"),
|
||||||
|
obs.linea_sin_guia(1, ""), obs.cantidad_cero(1), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
|
||||||
|
]
|
||||||
|
for o in muestras:
|
||||||
|
assert o.titulo and o.como_corregir and o.codigo
|
||||||
|
assert len({o.codigo for o in muestras}) == len(muestras)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_dict_es_serializable():
|
||||||
|
d = obs.guia_no_encontrada("T001-9").a_dict()
|
||||||
|
assert d == {"codigo": "guia_no_encontrada", "titulo": "No encontramos la guía T001-9",
|
||||||
|
"filas": [{"etiqueta": "Guía", "valor": "T001-9"}], "como_corregir": d["como_corregir"]}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import outlook_utilities as o
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# es_mensaje_automatico
|
||||||
|
# ============================================================================
|
||||||
|
def _h(**kv):
|
||||||
|
return [{"name": k.replace("_", "-"), "value": v} for k, v in kv.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mensajes_normales_no_son_automaticos():
|
||||||
|
assert o.es_mensaje_automatico(None) is False
|
||||||
|
assert o.es_mensaje_automatico([]) is False
|
||||||
|
assert o.es_mensaje_automatico(_h(Auto_Submitted="no", Content_Type="text/html", Precedence="normal")) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mensajes_automaticos():
|
||||||
|
assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-replied")) is True
|
||||||
|
assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-generated")) is True
|
||||||
|
assert o.es_mensaje_automatico(_h(X_Auto_Response_Suppress="All")) is True
|
||||||
|
assert o.es_mensaje_automatico(_h(X_Autoreply="yes")) is True
|
||||||
|
assert o.es_mensaje_automatico(_h(Precedence="bulk")) is True
|
||||||
|
assert o.es_mensaje_automatico(_h(Precedence="auto_reply")) is True
|
||||||
|
assert o.es_mensaje_automatico(_h(Content_Type="multipart/report; report-type=delivery-status")) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_los_nombres_de_encabezado_no_distinguen_mayusculas():
|
||||||
|
assert o.es_mensaje_automatico([{"name": "AUTO-SUBMITTED", "value": "Auto-Replied"}]) is True
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# send_email (payload hacia Graph)
|
||||||
|
# ============================================================================
|
||||||
|
class _Respuesta:
|
||||||
|
def __init__(self, status=202, text=""):
|
||||||
|
self.status_code, self.text = status, text
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_incluye_destinatarios_copia_y_copia_oculta(monkeypatch):
|
||||||
|
enviado = {}
|
||||||
|
monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(url=url, **k) or _Respuesta())
|
||||||
|
|
||||||
|
ok = o.send_email("tok", "buzon@x.com", ["a@x.com"], "Asunto", "<p>hola</p>", None,
|
||||||
|
cc_recipients=["c@x.com"], bcc_recipients=["oculto@x.com"])
|
||||||
|
|
||||||
|
mensaje = enviado["json"]["message"]
|
||||||
|
assert ok is True and enviado["url"].endswith("/users/buzon@x.com/sendMail")
|
||||||
|
assert mensaje["toRecipients"] == [{"emailAddress": {"address": "a@x.com"}}]
|
||||||
|
assert mensaje["ccRecipients"] == [{"emailAddress": {"address": "c@x.com"}}]
|
||||||
|
assert mensaje["bccRecipients"] == [{"emailAddress": {"address": "oculto@x.com"}}]
|
||||||
|
assert mensaje["attachments"] == [] and mensaje["body"]["contentType"] == "HTML"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_sin_copia_oculta_envia_la_lista_vacia(monkeypatch):
|
||||||
|
enviado = {}
|
||||||
|
monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(**k) or _Respuesta())
|
||||||
|
o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>")
|
||||||
|
assert enviado["json"]["message"]["bccRecipients"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_devuelve_false_si_graph_rechaza_o_falla(monkeypatch):
|
||||||
|
monkeypatch.setattr(o.requests, "post", lambda *a, **k: _Respuesta(400, "mal"))
|
||||||
|
assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>") is False
|
||||||
|
|
||||||
|
def explota(*a, **k):
|
||||||
|
raise ConnectionError("sin red")
|
||||||
|
monkeypatch.setattr(o.requests, "post", explota)
|
||||||
|
assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# get_emails
|
||||||
|
# ============================================================================
|
||||||
|
def test_get_emails_pide_los_encabezados_y_marca_los_automaticos(monkeypatch):
|
||||||
|
pedido = {}
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {"value": [
|
||||||
|
{"id": "1", "receivedDateTime": "2026-10-14T15:35:00Z", "hasAttachments": True, "subject": "Factura",
|
||||||
|
"from": {"emailAddress": {"address": "a@x.com"}}, "internetMessageHeaders": []},
|
||||||
|
{"id": "2", "receivedDateTime": "2026-10-14T16:00:00Z", "hasAttachments": False, "subject": "Auto",
|
||||||
|
"from": {"emailAddress": {"address": "b@x.com"}},
|
||||||
|
"internetMessageHeaders": [{"name": "Auto-Submitted", "value": "auto-replied"}]},
|
||||||
|
{"id": "3", "receivedDateTime": "2026-10-14T16:00:00Z", "subject": "Sin encabezados", "from": {}},
|
||||||
|
]}
|
||||||
|
|
||||||
|
monkeypatch.setattr(o.requests, "get", lambda url, **k: pedido.update(k) or Resp())
|
||||||
|
|
||||||
|
correos = o.get_emails("buzon@x.com", "tok", "2026-10-14", "2026-10-14")
|
||||||
|
|
||||||
|
assert "internetMessageHeaders" in pedido["params"]["$select"]
|
||||||
|
assert [c["es_automatico"] for c in correos] == [False, True, False]
|
||||||
|
assert correos[0]["fecha"] == "2026-10-14 10:35:00" # UTC-5
|
||||||
|
assert correos[2]["sender"] == "Desconocido"
|
||||||
+264
-73
@@ -1,7 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y la
|
Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y Graph
|
||||||
BD simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de
|
simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de 624.38
|
||||||
624.38 con la guía T001-128912).
|
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 glob
|
||||||
import logging
|
import logging
|
||||||
@@ -21,39 +25,60 @@ from conftest import FIXTURES
|
|||||||
|
|
||||||
CASO = "001_correo"
|
CASO = "001_correo"
|
||||||
TOTAL_LINEA = 624.38
|
TOTAL_LINEA = 624.38
|
||||||
|
SENDER = "proveedor@empresa.com"
|
||||||
|
EQUIPO = ["equipo1@aberio.com", "equipo2@aberio.com"]
|
||||||
|
|
||||||
|
|
||||||
class Entorno:
|
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):
|
def __init__(self):
|
||||||
self.line_total_sap = TOTAL_LINEA
|
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.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.drafts = []
|
||||||
self.correos = []
|
self.correos = []
|
||||||
self.descargas = 0
|
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
|
@pytest.fixture
|
||||||
def entorno(tmp_path, monkeypatch):
|
def entorno(tmp_path, monkeypatch):
|
||||||
e = Entorno()
|
e = Entorno()
|
||||||
monkeypatch.setenv("RUTA_RED_FACTURACION", "")
|
monkeypatch.setenv("RUTA_RED_FACTURACION", "")
|
||||||
|
monkeypatch.setenv("NOTIFICACION_EQUIPO", ",".join(EQUIPO))
|
||||||
|
|
||||||
monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: [])
|
monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: [])
|
||||||
monkeypatch.setattr(database_utilities, "save_emails", lambda emails: None)
|
|
||||||
|
|
||||||
def descargar(addr, id_, token, carpeta):
|
def descargar(addr, id_, token, carpeta):
|
||||||
e.descargas += 1
|
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 = []
|
nombres = []
|
||||||
|
if e.archivos is None:
|
||||||
for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")):
|
for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")):
|
||||||
if ruta.lower().endswith((".pdf", ".xml")):
|
if ruta.lower().endswith((".pdf", ".xml")):
|
||||||
shutil.copy(ruta, carpeta)
|
shutil.copy(ruta, carpeta)
|
||||||
nombres.append(os.path.basename(ruta))
|
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
|
return nombres
|
||||||
|
|
||||||
def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None):
|
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, "adjuntos": adjuntos})
|
e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients,
|
||||||
|
"bcc": bcc_recipients, "adjuntos": adjuntos})
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def crear_draft(draft):
|
def crear_draft(draft):
|
||||||
@@ -63,10 +88,18 @@ def entorno(tmp_path, monkeypatch):
|
|||||||
return {"DocEntry": 777}
|
return {"DocEntry": 777}
|
||||||
|
|
||||||
def delivery_notes(**k):
|
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": [
|
return [{"Series": "1", "DocNum": "9", "U_IDC_DESTINO": "Z1", "DocumentLines": [
|
||||||
{"ItemCode": "FL10007", "LineTotal": e.line_total_sap, "PriceAfterVAT": 1.3396,
|
{"ItemCode": "FL10007", "LineTotal": e.line_total_sap, "PriceAfterVAT": 1.3396,
|
||||||
"Quantity": 466, "WarehouseCode": "A-CENTRA", "Weight1": 1}]}]
|
"Quantity": 466, "WarehouseCode": "A-CENTRA", "Weight1": 1}]}]
|
||||||
|
|
||||||
|
class ConexionHana:
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
monkeypatch.setattr(outlook_utilities, "download_attachments", descargar)
|
monkeypatch.setattr(outlook_utilities, "download_attachments", descargar)
|
||||||
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
|
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
|
||||||
monkeypatch.setattr(sapb1_manager, "get_zonas", lambda: [{"Code": "Z1", "Name": "ZONA1"}])
|
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, "obtener_condicion_pago", lambda c: {"NumberOfAdditionalDays": 30})
|
||||||
monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft)
|
monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft)
|
||||||
monkeypatch.setattr(hana_manager, "obtener_valor_referencial", lambda s, n: 0.0)
|
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.descarga = tmp_path / "descarga"
|
||||||
e.salida = tmp_path / "salida"
|
e.salida = tmp_path / "salida"
|
||||||
return e
|
return e
|
||||||
|
|
||||||
|
|
||||||
def _procesar(e, sender="proveedor@empresa.com"):
|
def _procesar(e, sender=SENDER, subject="Factura", id_="abc", es_automatico=False, carpeta=None):
|
||||||
email = {"id": "abc", "fecha": "2026-10-14 10:00:00", "subject": "Factura", "sender": sender}
|
email = {"id": id_, "fecha": "2026-10-14 10:35:00", "subject": subject, "sender": sender,
|
||||||
pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(e.descarga), str(e.salida))
|
"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
|
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)
|
_procesar(entorno)
|
||||||
|
|
||||||
assert len(entorno.drafts) == 1
|
assert len(entorno.drafts) == 1
|
||||||
draft = entorno.drafts[0]
|
draft = entorno.drafts[0]
|
||||||
assert draft["CardCode"] == "PL10060028686"
|
assert draft["CardCode"] == "PL10060028686" and draft["NumAtCard"] == "01F002-2450"
|
||||||
assert draft["NumAtCard"] == "01F002-2450"
|
|
||||||
assert draft["DocumentLines"][0]["U_IDC_GRFLETE"] == "09T001-128912"
|
|
||||||
|
|
||||||
assert len(entorno.correos) == 1
|
(respuesta,) = entorno.respuestas
|
||||||
correo = entorno.correos[0]
|
assert respuesta["para"] == [SENDER] # al que envió el correo
|
||||||
assert correo["para"] == ["proveedor@empresa.com"]
|
assert respuesta["bcc"] == EQUIPO # el equipo en copia oculta
|
||||||
assert correo["asunto"] == "Draft SAP B1 - F002-2450"
|
assert respuesta["cc"] is None and respuesta["adjuntos"] is None
|
||||||
assert "creado exitosamente" in correo["html"] and "777" in correo["html"]
|
assert respuesta["asunto"].startswith("Aceptado · Factura F002-2450 · ABR-")
|
||||||
assert correo["cc"] # siempre se copia al equipo
|
assert "Registramos su factura" in respuesta["html"]
|
||||||
assert any(a.endswith(".xml") for a in correo["adjuntos"])
|
assert "09T001-128912" not in respuesta["html"] and "T001-128912" in respuesta["html"]
|
||||||
|
assert "S/ 624.38" in respuesta["html"]
|
||||||
|
assert "Pago estimado" in respuesta["html"] # hay condición de pago de 30 días
|
||||||
|
|
||||||
|
|
||||||
def test_precios_no_coinciden_no_crea_draft_pero_notifica(entorno):
|
def test_el_correo_tecnico_va_solo_al_equipo_nunca_al_remitente(entorno, monkeypatch):
|
||||||
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||||
|
|
||||||
|
_procesar(entorno)
|
||||||
|
|
||||||
|
(tecnico,) = entorno.tecnicos
|
||||||
|
assert tecnico["para"] == EQUIPO
|
||||||
|
assert SENDER not in tecnico["para"] and not tecnico["cc"] and not tecnico["bcc"]
|
||||||
|
assert any(a.endswith(".xml") for a in tecnico["adjuntos"]) # lleva los adjuntos internos
|
||||||
|
assert f"<b>Remitente:</b> {SENDER}" in tecnico["html"] and "ABR-" in tecnico["html"]
|
||||||
|
assert SENDER not in [d for c in entorno.respuestas for d in c["para"] if c["asunto"].startswith("[")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_modo_prueba_es_el_predeterminado_y_no_escribe_al_remitente(entorno):
|
||||||
|
_procesar(entorno) # NOTIFICACION_MODO sin definir
|
||||||
|
|
||||||
|
(respuesta,) = entorno.respuestas
|
||||||
|
assert respuesta["para"] == EQUIPO
|
||||||
|
assert respuesta["bcc"] is None
|
||||||
|
assert respuesta["asunto"].startswith(f"[PRUEBA → {SENDER}] Aceptado · Factura F002-2450")
|
||||||
|
assert all(SENDER not in c["para"] for c in entorno.correos) # a nadie fuera del equipo
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Rechazado
|
||||||
|
# ============================================================================
|
||||||
|
def test_precios_distintos_rechaza_y_muestra_ambos_importes(entorno, monkeypatch):
|
||||||
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||||
entorno.line_total_sap = TOTAL_LINEA + 50
|
entorno.line_total_sap = TOTAL_LINEA + 50
|
||||||
|
|
||||||
_procesar(entorno)
|
_procesar(entorno)
|
||||||
|
|
||||||
assert entorno.drafts == []
|
assert entorno.drafts == []
|
||||||
assert len(entorno.correos) == 1
|
(respuesta,) = entorno.respuestas
|
||||||
assert "Draft NO creado" in entorno.correos[0]["html"]
|
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")
|
entorno.error_al_crear = RuntimeError("Service Layer caído")
|
||||||
|
|
||||||
_procesar(entorno)
|
_procesar(entorno)
|
||||||
|
|
||||||
assert entorno.drafts == []
|
assert entorno.respuestas == []
|
||||||
assert len(entorno.correos) == 1
|
(tecnico,) = entorno.tecnicos
|
||||||
assert "Error al crear Draft" in entorno.correos[0]["html"]
|
assert "Error al crear Draft" in tecnico["html"] and "Service Layer caído" in tecnico["html"]
|
||||||
assert "Service Layer caído" in entorno.correos[0]["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")
|
_procesar(entorno, sender="Desconocido")
|
||||||
|
|
||||||
assert len(entorno.drafts) == 1 # el draft se crea igual
|
assert len(entorno.drafts) == 1
|
||||||
assert entorno.correos == []
|
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
|
||||||
|
assert _registros()[0]["notificacion_estado"] == "omitida"
|
||||||
|
|
||||||
|
|
||||||
def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
|
def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
|
||||||
@@ -137,8 +337,7 @@ def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
|
|||||||
|
|
||||||
_procesar(entorno)
|
_procesar(entorno)
|
||||||
|
|
||||||
assert entorno.descargas == 0
|
assert entorno.descargas == 0 and entorno.drafts == [] and entorno.correos == []
|
||||||
assert entorno.drafts == [] and entorno.correos == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_guarda_resultados_y_draft_en_disco(entorno):
|
def test_guarda_resultados_y_draft_en_disco(entorno):
|
||||||
@@ -149,61 +348,40 @@ def test_guarda_resultados_y_draft_en_disco(entorno):
|
|||||||
assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres)
|
assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
def test_aceptado_deja_el_resultado_y_la_notificacion_registrados(entorno, monkeypatch):
|
||||||
# Seguimiento
|
monkeypatch.setenv("NOTIFICACION_MODO", "real")
|
||||||
# ============================================================================
|
|
||||||
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_draft_creado_deja_el_comprobante_aceptado(entorno):
|
|
||||||
_procesar(entorno)
|
_procesar(entorno)
|
||||||
|
|
||||||
(registro,) = _registros()
|
(registro,) = _registros()
|
||||||
assert registro["estado"] == "aceptado"
|
assert (registro["estado"], registro["doc_entry"], registro["tipo"]) == ("aceptado", 777, "01")
|
||||||
assert registro["doc_entry"] == 777
|
assert (registro["serie"], registro["numero"], registro["ruc_emisor"]) == ("F002", "2450", "10060028686")
|
||||||
assert registro["codigo_seguimiento"].startswith("ABR-")
|
assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER
|
||||||
assert (registro["serie"], registro["numero"], registro["tipo"]) == ("F002", "2450", "01")
|
|
||||||
assert registro["ruc_emisor"] == "10060028686"
|
|
||||||
|
|
||||||
|
|
||||||
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
|
entorno.line_total_sap = TOTAL_LINEA + 50
|
||||||
|
|
||||||
_procesar(entorno)
|
_procesar(entorno)
|
||||||
|
|
||||||
(registro,) = _registros()
|
(registro,) = _registros()
|
||||||
assert registro["estado"] == "rechazado"
|
(observacion,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
|
||||||
assert registro["doc_entry"] is None
|
assert observacion["codigo"] == "precio_no_coincide"
|
||||||
assert seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] == [{"codigo": "precios_no_coinciden"}]
|
assert {"etiqueta": "Importe esperado", "valor": "S/ 674.38"} in observacion["filas"]
|
||||||
|
|
||||||
|
|
||||||
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"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog):
|
def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog):
|
||||||
_procesar(entorno)
|
_procesar(entorno)
|
||||||
(primero,) = _registros()
|
(primero,) = _registros()
|
||||||
|
|
||||||
entorno.descarga = entorno.descarga.parent / "descarga2" # otra descarga, otro correo
|
|
||||||
with caplog.at_level(logging.WARNING, logger="pipeline"):
|
with caplog.at_level(logging.WARNING, logger="pipeline"):
|
||||||
pipeline._procesar_un_correo(
|
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
|
||||||
{"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),
|
|
||||||
)
|
|
||||||
|
|
||||||
(registro,) = _registros() # sigue habiendo una sola fila
|
(registro,) = _registros() # sigue habiendo una sola fila
|
||||||
assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"]
|
assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"]
|
||||||
@@ -211,7 +389,9 @@ def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog)
|
|||||||
assert any("ya recibido antes" in r.getMessage() for r in caplog.records)
|
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):
|
def roto(*a, **k):
|
||||||
raise sqlite3.OperationalError("database is locked")
|
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)
|
_procesar(entorno)
|
||||||
|
|
||||||
assert len(entorno.drafts) == 1
|
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"
|
||||||
@@ -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", "<p/>", ["a.xml"]) is True
|
||||||
|
assert graph[0]["para"] == ["eq@aberio.com"]
|
||||||
@@ -147,3 +147,66 @@ def test_la_tabla_emails_existente_no_se_modifica():
|
|||||||
database_utilities.init_db()
|
database_utilities.init_db()
|
||||||
|
|
||||||
assert seguimiento.registrar_correo("correo-1")["codigo_seguimiento"].startswith("ABR-")
|
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"
|
||||||
|
|
||||||
Reference in new issue
Block a user