7 Commits
Author SHA1 Message Date
GeorgeandClaude Sonnet 5.5 ca1550bced versión 1.2.0: despliegue como servicio de Windows con NSSM
- deploy/: scripts de PowerShell para instalar el servicio (cuenta de servicio
  con acceso a la ruta de red, reinicio automático, parada con Ctrl+C,
  rotación de la salida, PYTHONUTF8), actualizar a una versión etiquetada o
  volver a una anterior, y desinstalar.
- docs/despliegue_windows.md: requisitos, instalación, operación, rollback y
  problemas frecuentes.
- tools/verificar_conexiones.py: comprueba .env, Graph, SAP, HANA y la ruta de
  red sin modificar nada.
- CHANGELOG.md, README e inventario; pyproject.toml pasa a 1.2.0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00
GeorgeandClaude Sonnet 5.5 fcce30eda6 se completan los horarios, se fijan las rutas al código y se muestra la versión
- HORARIOS cubre las 24 horas: se agregan 21:30-24:00 (cada 60 min) y
  05:00-06:30 (cada 30 min); antes el demonio dormía hasta las 06:30 en esos
  tramos. Se corrigen los comentarios, que no coincidían con los valores.
  tests/test_horarios.py garantiza que no haya huecos ni solapes.
- La carpeta de resultados se resuelve junto al código, no relativa al cwd
  (un servicio de Windows puede arrancar desde cualquier carpeta).
- copiar_a_red no crea carpetas locales cuando RUTA_RED_FACTURACION no es una
  ruta absoluta en el sistema operativo (una UNC fuera de Windows); antes el
  draft podía referenciar archivos que no estaban en el recurso compartido.
- El log muestra la versión del proyecto (pyproject.toml) al arrancar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00
GeorgeandClaude Sonnet 5.5 1c57988f54 se valida si la factura ya existe en SAP y se avisa cuando SAP está fuera de línea
- sapb1_manager.buscar_factura_existente busca la factura (proveedor, tipo,
  serie, número) en facturas de compra no anuladas y en drafts; si existe no
  se crea otro draft y se responde "ya está registrada" sin pisar el registro
  de una aceptación anterior.
- SapNoDisponible (subclase de ConnectionError) distingue una caída (timeout,
  sin conexión, HTTP 5xx) de un rechazo normal de SAP (HTTP 4xx). Una caída
  de Service Layer con HANA sin resultados ya no se lee como "guía no existe".
- Con SAP o HANA caídos se avisa una sola vez al proveedor con la plantilla
  "En cola" y el correo se reintenta en los ciclos siguientes (hasta 24), sin
  repetir avisos; al agotarse se avisa al equipo.
- La lógica por factura pasa a _procesar_factura; lo ya resuelto dentro de un
  correo no se repite al reintentarlo.
- seguimiento: columna intentos (con migración), registrar_intento y el
  estado de notificación en_espera.
- database_utilities.DB_NAME pasa a ser absoluto (junto al código).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 12:08:38 -05:00
GeorgeandClaude Sonnet 5.5 5e533b7a9f se elimina tools/documento.json (token de ngrok sin uso)
El archivo no lo usaba ningún módulo. El token sigue en el historial de git
(commit 99b7caf): conviene revocarlo en el panel de ngrok.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 11:54:16 -05:00
GeorgeandClaude Sonnet 5.5 108d52da33 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>
2026-10-22 08:55:54 -05:00
GeorgeandClaude Sonnet 5.5 190fcc8f9e se documenta cómo obtener CLIENT_ID, TENANT_ID y CLIENT_SECRET de Azure
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:55:54 -05:00
GeorgeandClaude Sonnet 5.5 7ae2580565 se agregan las plantillas de aceptado/rechazado y su renderizador
plantilla/ con los dos HTML de correo y ejemplos.json (6 escenarios ficticios);
plantillas.py renderiza el subconjunto de Mustache que usan (variables con
escape de HTML, secciones e invertidas), en modo estricto: un dato que falta
da error en vez de un correo con huecos. tools/previsualizar_plantillas.py
genera las vistas previas y envía correos de prueba (--enviar). En la
plantilla de rechazo el RUC, la fila Comprobante y la tabla de guías son
opcionales, para el rechazo de un correo sin comprobante.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 08:55:54 -05:00
50 changed files with 4200 additions and 209 deletions

No files matched your search

+12
View File
@@ -26,6 +26,7 @@ HANA_PASSWORD =
HANA_SCHEMA =
# --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) ---
# Cómo obtener estos valores: docs/configurar_msal.md
CLIENT_ID =
CLIENT_SECRET =
TENANT_ID =
@@ -33,3 +34,14 @@ VERIFY_SSL = false
# --- Logging (ver logger_manager.py). Opcional, por defecto INFO ---
LOG_LEVEL = INFO
# --- Respuesta al proveedor (aceptado / rechazado), ver docs/respuesta_proveedor.md ---
# prueba (por defecto): la respuesta va al equipo con asunto "[PRUEBA → remitente]", NO al proveedor.
# real: la respuesta va a quien envió el correo, con copia oculta al equipo.
NOTIFICACION_MODO = prueba
# Direcciones internas (coma o punto y coma): reciben el correo técnico y las respuestas en modo prueba.
NOTIFICACION_EQUIPO = equipo1@dominio.com,equipo2@dominio.com
# Contacto del botón "Escribir a Aberio" (por defecto, el buzón EMAIL).
NOTIFICACION_CONTACTO =
# URL pública (https) del logo que se muestra en el correo. Vacío = sin logo.
LOGO_URL =
+1
View File
@@ -14,3 +14,4 @@ emails.db
*.egg-info/
build/
procesos/
tools/previews/
+42
View File
@@ -0,0 +1,42 @@
# Historial de cambios
Las versiones siguen [SemVer](https://semver.org/lang/es/) y se publican como etiquetas de git (`v1.2.0`). La versión vigente está en `pyproject.toml` y se muestra en el log al arrancar.
## [1.2.0] — 2026-10-22
### Agregado
- **Respuesta al proveedor** con las plantillas HTML de aceptado y rechazado (`plantilla/`), enviada a quien envió el correo. Ver `docs/respuesta_proveedor.md`.
- `NOTIFICACION_MODO`: `prueba` (por defecto; la respuesta va al equipo, no al proveedor) o `real` (al remitente, con copia oculta al equipo).
- Protecciones: no responde a remitentes desconocidos o sin dominio válido, al propio buzón, a `no-reply`, a mensajes automáticos ni rebotes, a respuestas de nuestras notificaciones, y limita a 3 respuestas por remitente cada 24 h en correos sin comprobante.
- Rechazo con observaciones estructuradas (título, detalle, cómo corregir) también para correos sin comprobante válido.
- **Se valida si la factura ya existe en SAP** (factura de compra no anulada o draft) antes de crear el draft; si existe, no se duplica y se responde que ya está registrada.
- **Servicio fuera de línea:** si SAP Service Layer o HANA no responden, se avisa una vez al proveedor ("en cola, no necesita reenviar") y el correo se reintenta en los ciclos siguientes, hasta 24 veces. Plantilla `plantilla-fuera-de-linea.html`.
- **Código de seguimiento** (`ABR-AAMMDD-NNNNN`) por comprobante, con su resultado y el estado de la notificación (`seguimiento.py`, tabla `comprobantes`).
- **Despliegue en Windows como servicio con NSSM** (`deploy/`, `docs/despliegue_windows.md`).
- Validación del `.env` al arrancar (`config_validator.py`), que además anuncia el modo de notificación.
- Versión del proyecto en el log al arrancar y `tools/verificar_conexiones.py` (comprueba el `.env`, Microsoft Graph, SAP, HANA y la ruta de red sin modificar nada).
- Suite de pruebas pytest (más de 280) y herramientas de vista previa y envío de prueba de las plantillas (`tools/previsualizar_plantillas.py`).
- Documentación: `README.md`, guía de MSAL (`docs/configurar_msal.md`) y una página por módulo principal.
### Cambiado
- El correo técnico (tablas de SAP y JSON adjuntos) va **solo al equipo**; antes iba también al remitente.
- Un error de SAP/HANA o un fallo inesperado ya no se le comunica al proveedor como rechazo y ya no interrumpe el ciclo: solo esa factura.
- "Guía no encontrada" exige que HANA responda: si no, se trata como servicio fuera de línea.
- `DueDate` de las cuotas del draft = fecha de vencimiento (antes, fecha de emisión).
- **Horarios sin huecos:** se agregan 21:30–24:00 (cada 60 min) y 05:00–06:30 (cada 30 min); antes el demonio dormía hasta las 06:30 en esos tramos.
- La base `emails.db` y la carpeta de resultados se resuelven junto al código, no relativas al directorio de trabajo.
- `copiar_a_red` ya no crea carpetas locales cuando `RUTA_RED_FACTURACION` no es una ruta absoluta en el sistema operativo (una ruta UNC fuera de Windows); antes el draft podía referenciar archivos que no estaban en el recurso compartido.
- `pipeline.py` se divide en módulos (`adjuntos`, `documentos`, `persistencia`, `draft_sap`, `notificaciones`).
- Los `print` de los módulos pasan a `logging` con nivel explícito (`LOG_LEVEL` filtra por severidad).
- `pyproject.toml` y `requirements.txt` alineados, con las dependencias directas fijadas.
- `consulta_rapida.py` y los scripts manuales pasan a `tools/`; los correos de ejemplo, a `tests/fixtures/`.
### Eliminado
- `tools/documento.json` (token de ngrok sin uso). **El token sigue en el historial de git: revóquelo.**
### Seguridad
- Pendiente de rotar: la contraseña de SAP y el token de ngrok, presentes en el historial de git (`tools/aberio.http`, `tools/documento.json`).
## [1.1.0]
Versión anterior a este registro: descarga de adjuntos, extracción de XML y PDF, validación de precios contra SAP B1, creación del draft y notificación por correo.
+16 -3
View File
@@ -8,9 +8,12 @@ Demonio que descarga las facturas de transporte (PDF/XML SUNAT) recibidas en un
Bandeja de entrada (Microsoft Graph)
→ descarga adjuntos, descomprime ZIP, se queda con PDF/XML
→ extrae datos de PDF y XML y asigna las guías de remisión por línea
→ comprueba que la factura no exista ya en SAP
→ valida precios de cada guía contra SAP (Service Layer, con fallback a HANA)
→ crea el Draft en SAP B1 si todos los precios coinciden
→ envía correo con el resultado (Draft creado / error / precios no coinciden)
→ si SAP no responde, avisa "en cola" y reintenta en los ciclos siguientes
→ 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).
@@ -37,10 +40,15 @@ El `.env` nunca se sube a git. Todas las variables están documentadas en [`.env
python main.py # demonio; consulta los últimos 2 días (--dias 2)
python main.py --dias 5 # ventana de búsqueda de 5 días
python tools/consulta_rapida.py --set <nombre> # reprocesar correos por ID (sets en tools/ids_prueba.json)
python tools/verificar_conexiones.py # comprueba .env, Graph, SAP, HANA y la ruta de red
```
El demonio trabaja por bloques horarios (cada 1, 5, 30 o 60 minutos según la hora) y duerme fuera de ellos. Los bloques están en `HORARIOS` de `main.py` y se explican en [`docs/main.md`](docs/main.md).
## Despliegue
En producción corre como **servicio de Windows con NSSM**: arranca con el servidor y se reinicia solo. La carpeta [`deploy/`](deploy/) trae los scripts (instalar, actualizar a una versión etiquetada y volver atrás, desinstalar) y [`docs/despliegue_windows.md`](docs/despliegue_windows.md) la guía completa. Antes de instalar, `python tools/verificar_conexiones.py` comprueba el `.env`, Microsoft Graph, SAP, HANA y la ruta de red. Las versiones son etiquetas de git (`v1.2.0`); el historial está en [`CHANGELOG.md`](CHANGELOG.md).
## Pruebas
```bash
@@ -70,12 +78,14 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
| `main.py` | Demonio y horarios |
| `pipeline.py` | Orquesta el proceso de cada correo |
| `adjuntos.py`, `documentos.py`, `persistencia.py` | Descarga de adjuntos, extracción de PDF/XML, guardado de JSON |
| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; cuerpo de los correos |
| `draft_sap.py`, `notificaciones.py` | Validación de precios y construcción del Draft; correo técnico para el equipo |
| `respuesta_proveedor.py`, `observaciones.py`, `plantillas.py` | Respuesta de aceptado/rechazado al remitente con las plantillas HTML (`plantilla/`) |
| `outlook_utilities.py`, `sapb1_manager.py`, `hana_manager.py` | Clientes de Microsoft Graph, SAP Service Layer y SAP HANA |
| `sunat_schema.py`, `pdf_utilities.py` | Lectura de XML SUNAT y de PDF |
| `database_utilities.py`, `seguimiento.py`, `email_utilities.py` | SQLite local; códigos de seguimiento y resultado por comprobante; ZIP y filtrado de adjuntos |
| `logger_manager.py`, `config_validator.py`, `pipeline_utils.py` | Logging, validación del `.env`, utilidades |
| `tools/` | Scripts manuales: reprocesar correos, regenerar/inspeccionar los correos de prueba, `aberio.http` |
| `deploy/` | Scripts de PowerShell para instalar el servicio de Windows con NSSM |
| `tools/` | Scripts manuales: verificar conexiones, vista previa de las plantillas, reprocesar correos, regenerar los correos de prueba, `aberio.http` |
| `tests/` | Suite pytest, correos de ejemplo (`tests/fixtures/`) y resultados esperados (`tests/golden/`) |
## Documentación
@@ -88,6 +98,9 @@ Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, e
| [`docs/procesar_pdfs.md`](docs/procesar_pdfs.md) / [`docs/procesar_xmls.md`](docs/procesar_xmls.md) | Extracción de datos de PDF y XML |
| [`docs/validar_precios.md`](docs/validar_precios.md) | Validación de precios contra SAP |
| [`docs/construir_draft.md`](docs/construir_draft.md) | Construcción del Draft SAP B1 |
| [`docs/respuesta_proveedor.md`](docs/respuesta_proveedor.md) | Respuesta de aceptado/rechazado al proveedor: modos, protecciones y observaciones |
| [`docs/seguimiento.md`](docs/seguimiento.md) | Códigos de seguimiento y resultado por comprobante |
| [`docs/despliegue_windows.md`](docs/despliegue_windows.md) | Instalación como servicio de Windows con NSSM, operación y rollback |
| [`docs/configurar_msal.md`](docs/configurar_msal.md) | Cómo obtener `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET` en Azure |
| [`docs/outlook_utilities.md`](docs/outlook_utilities.md) | Cliente de Microsoft Graph |
| [`docs/hana_manager.md`](docs/hana_manager.md) | Consultas directas a SAP HANA |
+15
View File
@@ -21,6 +21,9 @@ OBLIGATORIAS = {
"SAP HANA": ["HANA_SERVER", "HANA_USER", "HANA_PASSWORD", "HANA_SCHEMA"],
}
# Modos de ``NOTIFICACION_MODO``: ver respuesta_proveedor.py
MODOS_NOTIFICACION = ("prueba", "real")
# Variable de verificación SSL → servicio al que aplica. Cada módulo solo
# la activa con el valor "true" (cualquier otro valor, o ausente, la desactiva).
VERIFICACION_SSL = {
@@ -55,6 +58,18 @@ def validar_configuracion() -> tuple[list[str], list[str]]:
"y U_VS_ANEXO_PDF/XML irán vacíos en el draft"
)
modo = os.getenv("NOTIFICACION_MODO", "").strip().lower()
if modo and modo not in MODOS_NOTIFICACION:
advertencias.append(f"NOTIFICACION_MODO='{modo}' no es válido (usa {' o '.join(MODOS_NOTIFICACION)}); se usará prueba")
elif modo == "real":
advertencias.append("Modo de notificación REAL: las respuestas se envían a quien envía el correo")
else:
advertencias.append("Modo de notificación PRUEBA: las respuestas van al equipo, no a quien envía el correo")
if not os.getenv("NOTIFICACION_EQUIPO", "").strip():
advertencias.append("NOTIFICACION_EQUIPO no definida: se usa la lista por defecto del equipo")
if not os.getenv("LOGO_URL", "").strip():
advertencias.append("LOGO_URL no definida: los correos al proveedor saldrán sin logo")
nivel = os.getenv("LOG_LEVEL", "").strip()
if nivel and not isinstance(getattr(logging, nivel.upper(), None), int):
advertencias.append(f"LOG_LEVEL='{nivel}' no es un nivel válido; se usará INFO")
+15 -1
View File
@@ -1,10 +1,13 @@
import os
import sqlite3
# ============================================================================
# CONFIGURACIÓN
# ============================================================================
DB_NAME = "emails.db"
# Junto al código, no relativa al directorio de trabajo: el demonio (por ejemplo como servicio de
# Windows) puede lanzarse desde cualquier carpeta y debe encontrar siempre la misma base.
DB_NAME = os.path.join(os.path.dirname(os.path.abspath(__file__)), "emails.db")
# ============================================================================
def init_db() -> None:
@@ -47,10 +50,21 @@ def init_db() -> None:
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente',
notificacion_error TEXT,
notificacion_fecha TEXT,
notificacion_destinatario TEXT,
intentos INTEGER NOT NULL DEFAULT 0,
creado_en TEXT NOT NULL,
actualizado_en TEXT NOT NULL
)
''')
# Bases creadas antes de agregar una columna: se añade sin tocar los datos.
columnas = {fila[1] for fila in cursor.execute("PRAGMA table_info(comprobantes)")}
for columna, definicion in (
("notificacion_destinatario", "TEXT"),
("intentos", "INTEGER NOT NULL DEFAULT 0"),
):
if columna not in columnas:
cursor.execute(f"ALTER TABLE comprobantes ADD COLUMN {columna} {definicion}")
cursor.execute('''
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_documento
ON comprobantes (ruc_emisor, tipo, serie, numero) WHERE serie IS NOT NULL
+40
View File
@@ -0,0 +1,40 @@
<#
.SYNOPSIS
Actualiza el proyecto a una versión (etiqueta de git) y reinicia el servicio. Sirve también para volver a una
versión anterior (rollback). Ejecutar en PowerShell como administrador.
.EXAMPLE
.\deploy\actualizar.ps1 -Version v1.2.0
.\deploy\actualizar.ps1 -Version v1.1.0 # volver a la anterior
#>
param(
[Parameter(Mandatory = $true)][string]$Version,
[string]$Nombre = "descarga_factura_transporte",
[string]$Nssm = "nssm"
)
$ErrorActionPreference = "Stop"
$Proyecto = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
Set-Location $Proyecto
if (git status --porcelain --untracked-files=no) {
throw "Hay cambios locales sin guardar en $Proyecto. Revíselos (git status) antes de actualizar."
}
function Comprobar($paso) { if ($LASTEXITCODE -ne 0) { throw "Falló: $paso (código $LASTEXITCODE)" } }
git fetch --tags origin
Comprobar "git fetch"
if (-not (git tag --list $Version)) { throw "No existe la etiqueta $Version. Etiquetas disponibles: $(git tag --list | Out-String)" }
& $Nssm stop $Nombre confirm | Out-Null
try {
git checkout $Version
Comprobar "git checkout $Version"
& (Join-Path $Proyecto "venv\Scripts\python.exe") -m pip install -r requirements.txt
Comprobar "pip install"
} finally {
& $Nssm start $Nombre | Out-Null # arranca siempre: si algo falló, vuelve a levantar lo que había
}
Write-Host "Servicio '$Nombre' en la versión $Version."
Write-Host "Compruebe la versión en el log: Select-String 'versión' logs\descarga_factura_transporte.log | Select-Object -Last 1"
+15
View File
@@ -0,0 +1,15 @@
<#
.SYNOPSIS
Detiene y elimina el servicio de Windows del demonio. No borra el proyecto, el .env ni emails.db.
Ejecutar en PowerShell como administrador.
#>
param(
[string]$Nombre = "descarga_factura_transporte",
[string]$Nssm = "nssm"
)
$ErrorActionPreference = "Stop"
if (-not (Get-Service -Name $Nombre -ErrorAction SilentlyContinue)) { Write-Host "El servicio '$Nombre' no existe."; return }
& $Nssm stop $Nombre confirm | Out-Null
& $Nssm remove $Nombre confirm
Write-Host "Servicio '$Nombre' eliminado. El proyecto, el .env y emails.db no se tocaron."
+99
View File
@@ -0,0 +1,99 @@
<#
.SYNOPSIS
Instala (o reinstala) el demonio descarga_factura_transporte como servicio de Windows con NSSM.
.DESCRIPTION
Ejecutar en PowerShell COMO ADMINISTRADOR, desde cualquier carpeta. Ver docs/despliegue_windows.md.
No arranca el servicio: primero revisa el .env y luego ejecuta nssm start <nombre>.
.PARAMETER Cuenta
Cuenta con la que corre el servicio, en formato DOMINIO\usuario. Debe tener acceso de lectura/escritura a la
ruta de red de RUTA_RED_FACTURACION: la cuenta LocalSystem (por defecto de los servicios) NO ve recursos de red.
.PARAMETER Clave
Contraseña de la cuenta (SecureString). Si se omite, se pide por consola.
.EXAMPLE
.\deploy\instalar_servicio.ps1 -Cuenta "ABERIO\svc_facturas"
#>
param(
[string]$Nombre = "descarga_factura_transporte",
[string]$Cuenta = "",
[securestring]$Clave,
[string]$Nssm = "nssm",
[string]$Python = ""
)
$ErrorActionPreference = "Stop"
# --- Rutas (la carpeta del proyecto es la que contiene deploy\) ---------------------------------------------
$Proyecto = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
if (-not $Python) { $Python = Join-Path $Proyecto "venv\Scripts\python.exe" }
# --- Comprobaciones previas ----------------------------------------------------------------------------------
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw "Ejecute este script como administrador."
}
if (-not (Get-Command $Nssm -ErrorAction SilentlyContinue)) {
throw "No se encontró nssm. Descárguelo de https://nssm.cc y agréguelo al PATH, o use -Nssm 'C:\ruta\nssm.exe'."
}
if (-not (Test-Path $Python)) {
throw "No existe $Python. Cree el entorno virtual: py -3 -m venv venv ; venv\Scripts\pip install -r requirements.txt"
}
if (-not (Test-Path (Join-Path $Proyecto ".env"))) {
throw "Falta $Proyecto\.env. Copie .env.example a .env y complételo (ver docs/configurar_msal.md)."
}
if (-not $Cuenta) {
Write-Warning "Sin -Cuenta el servicio corre como LocalSystem y NO podrá escribir en la ruta de red de facturación electrónica."
}
# --- Instalación ----------------------------------------------------------------------------------------------
if (Get-Service -Name $Nombre -ErrorAction SilentlyContinue) {
Write-Host "El servicio '$Nombre' ya existe: se detiene y se reconfigura."
& $Nssm stop $Nombre confirm | Out-Null
} else {
& $Nssm install $Nombre $Python "main.py" | Out-Null
}
$LogsNssm = Join-Path $Proyecto "logs"
New-Item -ItemType Directory -Force -Path $LogsNssm | Out-Null
& $Nssm set $Nombre Application $Python
& $Nssm set $Nombre AppParameters "main.py"
& $Nssm set $Nombre AppDirectory $Proyecto # el código no depende del cwd, pero el .env y los logs sí cuentan con él
& $Nssm set $Nombre DisplayName "Descarga de facturas de transporte"
& $Nssm set $Nombre Description "Lee el buzón, valida las facturas contra SAP B1, crea el draft y responde al proveedor."
& $Nssm set $Nombre Start SERVICE_AUTO_START
& $Nssm set $Nombre AppEnvironmentExtra "PYTHONUTF8=1" "PYTHONIOENCODING=utf-8" "PYTHONUNBUFFERED=1" # emojis del log en la consola de Windows
# Salida estándar y de error del proceso (el log de la aplicación sigue en logs\descarga_factura_transporte.log)
& $Nssm set $Nombre AppStdout (Join-Path $LogsNssm "servicio.out.log")
& $Nssm set $Nombre AppStderr (Join-Path $LogsNssm "servicio.err.log")
& $Nssm set $Nombre AppRotateFiles 1
& $Nssm set $Nombre AppRotateOnline 1
& $Nssm set $Nombre AppRotateBytes 10485760 # 10 MB
# Reinicio automático si el proceso muere; espera 30 s para no entrar en un bucle rápido
& $Nssm set $Nombre AppExit Default Restart
& $Nssm set $Nombre AppRestartDelay 30000
& $Nssm set $Nombre AppThrottle 10000
# Parada limpia: Ctrl+C (el demonio lo atiende con KeyboardInterrupt) y 20 s de gracia antes de forzar
& $Nssm set $Nombre AppStopMethodSkip 0
& $Nssm set $Nombre AppStopMethodConsole 20000
& $Nssm set $Nombre AppStopMethodWindow 0
& $Nssm set $Nombre AppStopMethodThreads 0
if ($Cuenta) {
if (-not $Clave) { $Clave = Read-Host "Contraseña de $Cuenta" -AsSecureString }
$Texto = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($Clave))
& $Nssm set $Nombre ObjectName $Cuenta $Texto
Remove-Variable Texto
}
Write-Host ""
Write-Host "Servicio '$Nombre' instalado. Pasos siguientes:"
Write-Host " 1. Revise $Proyecto\.env (NOTIFICACION_MODO, NOTIFICACION_EQUIPO, LOGO_URL)."
Write-Host " 2. nssm start $Nombre"
Write-Host " 3. Get-Content '$LogsNssm\descarga_factura_transporte.log' -Wait -Tail 30"
+88
View File
@@ -0,0 +1,88 @@
# Configurar la aplicación de Microsoft Graph (MSAL)
El proyecto lee y envía correo con Microsoft Graph usando una **aplicación registrada en Microsoft Entra ID** (antes Azure AD) y el flujo *client credentials*: no hay usuario interactivo, la aplicación se autentica sola con un `CLIENT_ID` y un `CLIENT_SECRET`. Este documento explica cómo obtener cada valor del `.env`.
| Variable del `.env` | De dónde sale |
|---------------------|---------------|
| `CLIENT_ID` | *Application (client) ID* de la aplicación |
| `TENANT_ID` | *Directory (tenant) ID* de la aplicación |
| `CLIENT_SECRET` | **Value** de un *client secret* creado en esa misma aplicación |
| `EMAIL` | Dirección del buzón que se lee y desde el que se envía |
> Los pasos describen el portal de Azure tal como suele verse; los nombres de los menús cambian de vez en cuando.
## 1. Registrar la aplicación
1. Entra a <https://entra.microsoft.com> (o al portal de Azure) con una cuenta con permisos para registrar aplicaciones.
2. **Identity → Applications → App registrations → New registration**.
3. Nombre, por ejemplo `descarga-factura-transporte`. En *Supported account types* elige **Accounts in this organizational directory only** (un solo tenant). No hace falta *Redirect URI*.
4. En la página **Overview** de la aplicación copia:
- **Application (client) ID** → `CLIENT_ID`
- **Directory (tenant) ID** → `TENANT_ID`
Los dos tienen que salir de la **misma** aplicación. Un `CLIENT_ID` de una aplicación con el `TENANT_ID` de otro directorio da el error `AADSTS700016`.
## 2. Dar permisos
Con el flujo *client credentials* se usan permisos de **aplicación**, no delegados.
1. **API permissions → Add a permission → Microsoft Graph → Application permissions**.
2. Agrega solo lo que el código usa:
| Permiso | Para qué | Dónde se usa |
|---------|----------|--------------|
| `Mail.Read` | Listar la bandeja de entrada y descargar adjuntos | `get_emails`, `download_attachments` |
| `Mail.Send` | Enviar las notificaciones | `send_email` |
3. Pulsa **Grant admin consent for <tu organización>**. Sin el consentimiento de administrador los permisos de aplicación no tienen efecto, aunque aparezcan en la lista.
> **Ojo:** un permiso de aplicación de correo vale, por defecto, para **todos los buzones** del tenant. Conviene limitarlo al buzón del proyecto con una política de acceso de aplicaciones en Exchange Online (*Application Access Policy* o *RBAC for Applications*). Eso lo configura un administrador de Exchange.
## 3. Crear el client secret
1. **Certificates & secrets → Client secrets → New client secret**.
2. Descripción y vencimiento (el portal permite hasta 24 meses).
3. Al crearlo, copia la columna **Value** y guárdala de inmediato en `CLIENT_SECRET`. **Solo se muestra una vez.**
Se confunde con frecuencia: la columna **Secret ID** es un identificador, no sirve como secret. Si usas el *Secret ID* el error es `AADSTS7000215`.
Cada secret **vence**. Anota la fecha en un calendario: cuando vence, el demonio deja de poder autenticarse y no lee ni envía correo (error `AADSTS7000222`). Para renovar sin cortes, crea el secret nuevo antes, actualiza el `.env`, reinicia el demonio y recién entonces borra el viejo.
## 4. Completar el `.env`
```
CLIENT_ID = <Application (client) ID>
TENANT_ID = <Directory (tenant) ID>
CLIENT_SECRET = <Value del secret>
EMAIL = buzon@tu-dominio.com
```
El `.env` no se sube a git. No pegues el secret en chats, tickets ni commits.
## 5. Verificar
Pide un token sin enviar nada. Imprime solo el resultado, no el secret:
```bash
python -c "
import re, outlook_utilities as o
try:
o.get_access_token(); print('OK: token obtenido')
except Exception as e:
print('ERROR', re.findall(r'AADSTS\d+', str(e)) or e)"
```
Un `OK` confirma `CLIENT_ID`, `TENANT_ID` y `CLIENT_SECRET`. No confirma los permisos sobre el buzón: eso se comprueba al listar o enviar un correo, por ejemplo con `tools/previsualizar_plantillas.py --escenario aceptado_sin_pago --enviar tu@correo`.
## Errores frecuentes
| Código | Significa | Qué revisar |
|--------|-----------|-------------|
| `AADSTS7000222` | El client secret **venció** | Crear un secret nuevo (paso 3) |
| `AADSTS7000215` | Secret inválido | Se usó el *Secret ID* en vez del *Value*, o hay un error al copiarlo |
| `AADSTS700016` | La aplicación no existe en ese directorio | `CLIENT_ID` y `TENANT_ID` deben ser de la misma aplicación (paso 1) |
| `AADSTS90002` | El tenant no existe | `TENANT_ID` mal escrito |
| `403` / `ErrorAccessDenied` al leer o enviar | Faltan permisos o consentimiento, o una política de acceso excluye al buzón | Paso 2 y la política de Exchange |
| `404` / `MailboxNotFound` | El buzón de `EMAIL` no existe o no tiene licencia | Revisar `EMAIL` |
Para cualquier código `AADSTS`, el mensaje completo trae un *Trace ID* y una explicación; la lista oficial está en la documentación de Microsoft Entra, sección de códigos de error de autenticación.
+109
View File
@@ -0,0 +1,109 @@
# Despliegue en Windows (servicio con NSSM)
El demonio (`main.py`) corre como **servicio de Windows** gestionado con [NSSM](https://nssm.cc): arranca con el servidor, se reinicia solo si se cae y se detiene de forma ordenada. La carpeta `deploy/` trae los scripts.
> Los scripts de `deploy/` se revisaron con el analizador de PowerShell (sintaxis correcta) pero **no se ejecutaron en un Windows real**. Pruébelos primero en un servidor de pruebas.
## Requisitos del servidor
| Requisito | Detalle |
|-----------|---------|
| Windows | Server 2016 o superior, o Windows 10/11 |
| Python | 3.11 o superior, **64 bits** (el cliente de SAP HANA, `hdbcli`, lo requiere) |
| Git y NSSM | `git` en el PATH y `nssm.exe` en el PATH (<https://nssm.cc>) |
| Red hacia fuera | `login.microsoftonline.com` y `graph.microsoft.com` (443) |
| Red hacia SAP | Service Layer (`SAP_B1_URL`, puerto 50000 por defecto) y HANA (`HANA_SERVER`, puerto 30015) |
| Ruta de red | Acceso de lectura y escritura a `RUTA_RED_FACTURACION` (recurso UNC) |
| Cuenta de servicio | Una cuenta de dominio, por ejemplo `ABERIO\svc_facturas`, con acceso a esa ruta de red |
| Zona horaria | El código asume hora de Lima (UTC-5) para la fecha y hora de recepción que ve el proveedor |
**La cuenta de servicio es importante.** Por defecto un servicio corre como `LocalSystem`, que **no ve recursos de red**: la copia a la ruta de facturación electrónica fallaría. Use una cuenta con permiso sobre ese recurso.
## Instalación
En PowerShell **como administrador**:
```powershell
cd C:\apps
git clone https://unasgeorge.vaquita-hadar.ts.net/george/descarga_factura_transporte.git
cd descarga_factura_transporte
git checkout v1.2.0 # siempre una versión etiquetada, no main
py -3 -m venv venv
venv\Scripts\python -m pip install -r requirements.txt
copy .env.example .env # y completar los valores
```
Complete el `.env` (cómo obtener las credenciales de Microsoft: [configurar_msal.md](configurar_msal.md)). **Empiece con `NOTIFICACION_MODO=prueba`**: las respuestas van al equipo y no a los proveedores. Defina `NOTIFICACION_EQUIPO` y `LOGO_URL`.
### Verificar antes de instalar el servicio
```powershell
venv\Scripts\python tools\verificar_conexiones.py
```
Revisa, sin modificar nada ni imprimir secretos: el `.env`, el token y la lectura del buzón de Microsoft Graph, SAP Service Layer, SAP HANA y la ruta de red de facturación (que debe ser accesible y escribible con la cuenta del servicio). Termina con código 1 si algo falla. Opcionalmente, las pruebas (no tocan SAP ni Outlook):
```powershell
venv\Scripts\python -m pip install pytest==9.1.1
venv\Scripts\python -m pytest -q
```
### Instalar y arrancar el servicio
```powershell
.\deploy\instalar_servicio.ps1 -Cuenta "ABERIO\svc_facturas" # pide la contraseña
nssm start descarga_factura_transporte
```
El script configura: arranque automático, directorio de trabajo del proyecto, reinicio automático a los 30 s si el proceso muere, parada con Ctrl+C (20 s de gracia), rotación de la salida del servicio a 10 MB y las variables `PYTHONUTF8=1` y `PYTHONIOENCODING=utf-8` (sin ellas los emojis del log dan error en la consola de Windows).
## Comprobar que funciona
```powershell
Get-Service descarga_factura_transporte
Get-Content logs\descarga_factura_transporte.log -Wait -Tail 30
```
En el log, al arrancar, debe verse `🚀 Demonio iniciado … (versión 1.2.0)`, y `config_validator` anuncia el modo de notificación (`PRUEBA` o `REAL`).
## Operación
| Tema | Detalle |
|------|---------|
| Logs de la aplicación | `logs\descarga_factura_transporte.log`, rotación diaria, 30 días |
| Salida del servicio | `logs\servicio.out.log` y `servicio.err.log` (10 MB, rotan). Mire `servicio.err.log` si el servicio arranca y se detiene |
| Cambiar el `.env` | `nssm restart descarga_factura_transporte` |
| Horarios | `HORARIOS` en `main.py` (24 h cubiertas, ver [main.md](main.md)) |
| Copia de seguridad | `emails.db` guarda los correos procesados y los códigos de seguimiento. Cópielo con el servicio detenido, o con `sqlite3 emails.db ".backup respaldo.db"` |
| Espacio en disco | `adjuntos\` y `resultado\` **no se limpian solos**: planifique una limpieza periódica |
| Pasar a modo real | Cuando el equipo haya validado las respuestas: `NOTIFICACION_MODO=real` y reiniciar |
## Actualizar y volver atrás (rollback)
Siempre a una **versión etiquetada** (ver [CHANGELOG](../CHANGELOG.md)):
```powershell
.\deploy\actualizar.ps1 -Version v1.2.0
.\deploy\actualizar.ps1 -Version v1.1.0 # volver a la anterior
```
El script detiene el servicio, hace `git checkout` de la etiqueta, instala las dependencias y arranca de nuevo (aunque algo falle, vuelve a levantar lo que había). No toca el `.env` ni `emails.db`. Se niega a actuar si hay cambios locales sin guardar.
## Desinstalar
```powershell
.\deploy\desinstalar_servicio.ps1 # no borra el proyecto, el .env ni emails.db
```
## Problemas frecuentes
| Síntoma | Causa probable |
|---------|----------------|
| El servicio arranca y se detiene enseguida | Falta el `.env`, o `config_validator` rechazó una variable: ver `logs\servicio.err.log` |
| `Access is denied` al copiar a la ruta de red | El servicio corre como `LocalSystem`. Reinstale con `-Cuenta` o cambie la cuenta en `services.msc` |
| `UnicodeEncodeError` en la salida del servicio | Falta `PYTHONUTF8=1`: reinstale con `instalar_servicio.ps1` |
| `AADSTS7000222` / `AADSTS700016` | Secret vencido / `CLIENT_ID` y `TENANT_ID` de aplicaciones distintas: ver [configurar_msal.md](configurar_msal.md) |
| `ConnectTimeout` hacia SAP o HANA | VPN o firewall: abra el puerto 50000 (Service Layer) y 30015 (HANA) desde el servidor |
| Los correos llegan sin logo | `LOGO_URL` vacío o no accesible públicamente |
+18 -13
View File
@@ -4,28 +4,33 @@
| Archivo | Líneas | Rol |
|---------|--------|-----|
| `main.py` | 137 | Demonio orquestador (loop de horarios) |
| `pipeline.py` | 272 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
| `pipeline_utils.py` | 27 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
| `main.py` | 142 | Demonio orquestador (loop de horarios) |
| `pipeline.py` | 437 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
| `pipeline_utils.py` | 38 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
| `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML |
| `documentos.py` | 372 | Extracción PDF/XML, asignación de guías, copia a red |
| `persistencia.py` | 163 | Guardado de JSON de resultados y de drafts |
| `draft_sap.py` | 414 | Validación de precios contra SAP, valor referencial y construcción del draft |
| `notificaciones.py` | 117 | Cuerpos HTML de los correos de notificación |
| `documentos.py` | 378 | Extracción PDF/XML, asignación de guías, copia a red |
| `persistencia.py` | 165 | Guardado de JSON de resultados y de drafts |
| `draft_sap.py` | 444 | Validación de precios contra SAP, valor referencial y construcción del draft |
| `notificaciones.py` | 121 | Cuerpos HTML de los correos de notificación |
| `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
| `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) |
| `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer |
| `outlook_utilities.py` | 249 | Comunicación con Microsoft Graph |
| `sapb1_manager.py` | 469 | Comunicación con SAP B1 Service Layer |
| `outlook_utilities.py` | 274 | Comunicación con Microsoft Graph |
| `hana_manager.py` | 141 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) |
| `database_utilities.py` | 150 | SQLite local (cache de correos) |
| `database_utilities.py` | 164 | 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/verificar_conexiones.py` | 85 | Comprueba `.env`, Graph, SAP, HANA y la ruta de red sin modificar nada |
| `deploy/*.ps1` | — | Instalar, actualizar (versión etiquetada / rollback) y desinstalar el servicio de Windows con NSSM |
| `tools/consulta_rapida.py` | 120 | Script auxiliar para reprocesar correos por ID (sets en `tools/ids_prueba.json`) |
| `seguimiento.py` | 187 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) |
| `config_validator.py` | 83 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) |
| `seguimiento.py` | 226 | Códigos de seguimiento y resultado por comprobante (tabla `comprobantes`) |
| `respuesta_proveedor.py` | 300 | Respuesta de aceptado/rechazado al remitente (modo prueba/real, protecciones, contextos) |
| `observaciones.py` | 165 | Catálogo de motivos de rechazo y sus textos |
| `plantillas.py` | 123 | Renderizador de las plantillas HTML de correo |
| `config_validator.py` | 98 | Valida el `.env` al arrancar (obligatorias, ruta de red, avisos de SSL) |
| `email_utilities.py` | 55 | Utilitarios de adjuntos (ZIP, filtrado) |
| `logger_manager.py` | 150 | Logging a archivo rotado (30 días) + consola + captura de `print()` |
Documentación por módulo: `seguimiento.py` → [seguimiento.md](seguimiento.md), `outlook_utilities.py` → [outlook_utilities.md](outlook_utilities.md), `hana_manager.py` → [hana_manager.md](hana_manager.md), validación y draft → [validar_precios.md](validar_precios.md) y [construir_draft.md](construir_draft.md).
Documentación por módulo: despliegue → [despliegue_windows.md](despliegue_windows.md), `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).
---
+6 -4
View File
@@ -4,7 +4,7 @@
```
main.py
├── Constantes: HORARIOS (5 bloques horarios)
├── Constantes: HORARIOS (7 bloques que cubren las 24 horas)
├── obtener_intervalo(dt) → (intervalo, modo) | None
├── segundos_hasta_proximo_inicio(dt) → int
├── non_negative_int(value) → int (validador argparse)
@@ -69,7 +69,7 @@ main()
│ │
│ └── ¿No está en horario?
│ │
│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente)
│ ├── dormir = segundos_hasta_proximo_inicio() (06:30 del día siguiente; rama inalcanzable mientras los bloques cubran el día)
│ └── Log "Fuera del horario, próxima ejecución en..."
│ │
│ └── 3.3. time.sleep(dormir) → vuelve a 3.1
@@ -81,13 +81,15 @@ main()
| Desde | Hasta | Intervalo | Modo |
|---------|---------|-----------|----------|
| 00:00 | 05:00 | 60s | rápido |
| 05:00 | 06:30 | 1800s | full |
| 06:30 | 13:15 | 300s | rápido |
| 13:15 | 14:00 | 1800s | full |
| 14:00 | 18:30 | 300s | rápido |
| 18:30 | 21:30 | 3600s | full |
| 00:00 | 05:00 | 60s | rápido |
| 21:30 | 24:00 | 3600s | full |
Nota: entre las 21:30 y las 00:00, y entre las 05:00 y las 06:30, ningún bloque cubre esos minutos. En esos huecos `obtener_intervalo()` devuelve `None` y `segundos_hasta_proximo_inicio()` calcula el tiempo de espera hasta las 06:30 del día siguiente (no hasta el bloque de las 00:00), por lo que el demonio se queda dormido hasta las 06:30 aunque exista un bloque configurado a las 00:00.
Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba `tests/test_horarios.py`). El modo (`rápido`/`full`) solo se registra en el log: `run_pipeline` se ejecuta igual en ambos. Antes de la versión 1.2.0 los tramos 21:30–00:00 y 05:00–06:30 no tenían bloque y el demonio dormía hasta las 06:30.
## Entry point (línea 126-134)
+5 -3
View File
@@ -1,6 +1,6 @@
# outlook_utilities.py — Microsoft Graph (Outlook)
Cliente mínimo de Microsoft Graph para leer la bandeja de entrada, bajar adjuntos y enviar las notificaciones. Autenticación con MSAL (client credentials, permisos de aplicación).
Cliente mínimo de Microsoft Graph para leer la bandeja de entrada, bajar adjuntos y enviar las notificaciones. Autenticación con MSAL (client credentials, permisos de aplicación). Cómo obtener las credenciales: [configurar_msal.md](configurar_msal.md).
## Estructura
@@ -19,9 +19,9 @@ outlook_utilities.py
| Función | Endpoint Graph | Qué hace |
|---------|----------------|----------|
| `get_access_token` | `login.microsoftonline.com/{TENANT_ID}` (scope `.default`) | Obtiene el token de aplicación. Lanza `Exception` si MSAL devuelve error. |
| `get_emails` | `GET /users/{user}/mailFolders/Inbox/messages` | Correos de la bandeja de entrada del rango, con paginación (`$top=50`, sigue `@odata.nextLink`). Devuelve `id`, `fecha`, `sender`, `subject`, `has_attach`. Lanza `Exception` si el status no es 200. |
| `get_emails` | `GET /users/{user}/mailFolders/Inbox/messages` | Correos de la bandeja de entrada del rango, con paginación (`$top=50`, sigue `@odata.nextLink`). Devuelve `id`, `fecha`, `sender`, `subject`, `has_attach` y `es_automatico` (calculado con los encabezados del mensaje). Lanza `Exception` si el status no es 200. |
| `download_attachments` | `GET /users/{user}/messages/{id}/attachments` | Guarda en `download_path` los adjuntos tipo archivo y devuelve sus nombres. Crea la carpeta si no existe. |
| `send_email` | `POST /users/{from}/sendMail` | Envía HTML con adjuntos y CC; guarda copia en Enviados. Devuelve `True` si Graph responde 202; `False` (con `ERROR` en el log) en cualquier otro caso. No lanza excepciones. |
| `send_email` | `POST /users/{from}/sendMail` | Envía HTML con adjuntos, CC y copia oculta (`bcc_recipients`); guarda copia en Enviados. Devuelve `True` si Graph responde 202; `False` (con `ERROR` en el log) en cualquier otro caso. No lanza excepciones. |
## Zona horaria
@@ -38,3 +38,5 @@ Los adjuntos se leen de las rutas indicadas. Una ruta inexistente se omite con u
- `VERIFY_SSL` se respeta en `get_access_token`, `get_emails` y `download_attachments`, pero `send_email` tiene `verify_ssl = False` fijo y lo ignora.
- El módulo ejecuta `warnings.filterwarnings("ignore")`, lo que silencia todas las advertencias de Python del proceso (incluidas las de certificado SSL).
- `get_access_token` retorna `None` (sin lanzar error) si MSAL devuelve un resultado vacío.
- `es_mensaje_automatico(headers)` marca como automáticos los mensajes con `Auto-Submitted` distinto de `no`, `X-Auto-Response-Suppress`, `X-Autoreply`, `Precedence` (`bulk`, `junk`, `list`, `auto_reply`) o `Content-Type` de informe de entrega (rebotes). El sistema no responde a esos mensajes.
+41 -10
View File
@@ -14,7 +14,10 @@ Módulos que orquesta _procesar_un_correo():
├── documentos.py procesar_pdfs, procesar_xmls, completar_guias, validar_guias, copiar_a_red
├── persistencia.py guardar_resultado, guardar_draft_json
├── draft_sap.py validate_prices, obtener_valor_referencial_sap, build_sap_draft
├── notificaciones.py build_validation_html, build_valor_referencial_html, build_notification_body
├── notificaciones.py build_validation_html, build_valor_referencial_html, build_notification_body (correo técnico)
├── observaciones.py motivos de rechazo con sus textos
├── respuesta_proveedor.py respuesta con plantilla al remitente (modo prueba/real, protecciones)
├── plantillas.py renderizador de las plantillas HTML
└── pipeline_utils.py log, ruta_carpeta
```
@@ -54,29 +57,38 @@ flowchart TD
F -- Sí --> G[4. _descomprimir ZIPs]
G --> H[5. filtrar_y_limpiar]
H --> I{Hay PDF/XML?}
I -- No --> C
I -- No --> RC[Rechazo del correo<br>responder al remitente]
I -- Sí --> J[6. procesar_pdfs]:::highlight
J --> K[7. procesar_xmls]:::highlight
K --> K2[8. completar_guias]
K2 --> K3[9. validar_guias]
K3 --> L{Hay documentos?}
L -- No --> C
L -- No --> RC
L -- Sí --> M[10. guardar_resultado]
M --> N[11. Filtrar facturas tipo 01]
N --> O{Hay facturas?}
O -- No --> C
O -- No --> RC
O -- Sí --> P[Bucle: por cada factura]
P --> Q[12. validate_prices]:::highlight
P --> P2{11.5 ¿Ya existe en SAP?}
P2 -- Sí --> V
P2 -- No --> Q[12. validate_prices]:::highlight
Q --> Q2[12.5 obtener_valor_referencial_sap]
Q2 --> R[13. build_sap_draft]:::highlight
R --> S[14. guardar_draft_json]
S --> T{Todas coinciden?}
T -- Sí --> U[15. sapb1_manager.create_draft]
T -- No --> V[⏭️ Draft NO creado]
U --> W[16. outlook_utilities.send_email]
S --> T{¿SAP/HANA caídos<br>u otro error?}
T -- Caída --> FL[estado fuera de línea:<br>aviso 'En cola' una vez<br>y el correo se reintenta]
T -- Otro error --> X[estado error]
T -- No --> T2{Todas coinciden?}
T2 -- Sí --> U[15. sapb1_manager.create_draft]
T2 -- No --> V[estado rechazado + observaciones]
U --> U2[estado aceptado]
U2 --> W[16. Correo técnico al equipo<br>+ respuesta con plantilla al remitente]
V --> W
X --> W2[16. Correo técnico al equipo<br>NO se responde al proveedor]
FL --> P
W --> P
W2 --> P
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
```
@@ -174,5 +186,24 @@ run_pipeline
│ ├── guardar_draft_json
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
│ ├── build_notification_body
│ └── outlook_utilities.send_email
│ ├── respuesta_proveedor.enviar_a_equipo (correo técnico, solo equipo)
│ └── respuesta_proveedor.responder (plantilla aceptado/rechazado, ver respuesta_proveedor.md)
```
---
## Qué se envía y a quién (paso 16)
| Resultado de la factura | Correo técnico (tablas de SAP, JSON adjuntos) | Respuesta con plantilla |
|-------------------------|-----------------------------------------------|-------------------------|
| `aceptado` (draft creado) | Equipo | Aceptado, al remitente |
| `rechazado` (precio, guía, o la factura **ya existe en SAP**) | Equipo | Rechazado con observaciones, al remitente |
| `fuera_de_linea` (SAP o HANA no responden) | Equipo (primer intento y al agotarse) | "En cola", **una sola vez**; el correo se reintenta |
| `error` (fallo inesperado, rechazo normal de SAP) | Equipo | **Ninguna**: es una falla nuestra, no del proveedor |
| Correo sin comprobante válido (sin PDF/XML, sin XML, sin factura) | — | Rechazado sin comprobante, al remitente |
| Correo sin adjuntos | — | Ninguna |
"Al remitente" depende de `NOTIFICACION_MODO` (en modo `prueba` va al equipo) y de las protecciones. Todo en [respuesta_proveedor.md](respuesta_proveedor.md).
Un fallo inesperado al procesar una factura ya no interrumpe el ciclo: se registra como `error`, se avisa al equipo y se sigue con la siguiente. La lógica por factura vive en `_procesar_factura`; `_procesar_un_correo` la llama una vez por factura y, si alguna pide reintento, deja el correo en cola.
+115
View File
@@ -0,0 +1,115 @@
# 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 |
| La factura **ya existe en SAP** (factura de compra no anulada o draft) | **Rechazado**: "Esta factura ya está registrada". No se crea otro draft |
| Correo con adjuntos pero sin PDF ni XML válido, o con PDF y sin XML, o sin ninguna factura (tipo 01) | **Rechazado** sin comprobante: no muestra proveedor, número ni tabla de guías |
| **SAP o HANA no responden** | **Aviso "En cola"** (una sola vez): se reintentará solo, no hace falta reenviar. Ver más abajo |
| Fallo inesperado, o un rechazo normal de SAP al crear el draft (HTTP 4xx) | **Nada**: es una falla nuestra. Solo se avisa al equipo |
| Correo sin adjuntos | **Nada** |
Una respuesta por comprobante: un correo con 3 facturas genera 3 respuestas. La aceptación significa "draft creado en SAP". La diferencia de valor referencial XML vs SAP es solo informativa y no rechaza.
> **Guía "no encontrada" y caídas.** `get_delivery_notes` ya no confunde una caída con una guía inexistente: si Service Layer no responde y HANA no aporta nada, lanza `SapNoDisponible`. Además, antes de rechazar por guía no encontrada se comprueba que HANA responde; si no, es "servicio fuera de línea" y no un rechazo.
## ¿La factura ya existe en SAP?
Antes de validar precios, `sapb1_manager.buscar_factura_existente` busca el comprobante por proveedor (`PL` + RUC), tipo, serie y número (`U_BPP_MDTD`/`U_BPP_MDSD`/`U_BPP_MDCD`) en las **facturas de compra no anuladas** y en los **drafts** de factura de compra. Si existe, no se crea otro draft y se responde con la observación `factura_ya_registrada`. Si el comprobante ya estaba **aceptado** en `comprobantes`, ese registro (estado y `doc_entry`) no se sobrescribe.
> La plantilla de rechazo dice "No pudimos registrar su factura… Corríjalas y envíenos el comprobante nuevamente", que no encaja del todo con un duplicado: la observación aclara que no hace falta reenviarla. Si prefiere una plantilla propia para este caso, es un cambio pequeño.
## Servicio fuera de línea y reintentos
`SapNoDisponible` (subclase de `ConnectionError`) se lanza solo ante una **caída**: timeout, sin conexión, HTTP 5xx o login imposible por esos motivos. Un HTTP 4xx (rechazo de SAP) o credenciales inválidas siguen siendo un `ConnectionError` normal y **no** se tratan como caída.
Cuando una factura se encuentra con una caída:
1. Se suma un intento en `comprobantes.intentos` y el resultado queda `error` con la observación `servicio_no_disponible`.
2. **Solo en el primer intento** se envía al proveedor la plantilla `plantilla-fuera-de-linea.html` ("En cola, la procesaremos automáticamente, no necesita reenviarla") y se avisa al equipo. La notificación queda `en_espera`. Los reintentos siguientes **no repiten** ningún aviso.
3. El correo se **elimina de `emails`**, de modo que el siguiente ciclo del demonio lo vuelve a tomar.
4. Cuando SAP vuelve, se procesa con normalidad y el proveedor recibe el aceptado o rechazado.
5. Tras `MAX_REINTENTOS` (24) intentos se deja de reintentar, se avisa al equipo ("Reintentos agotados") y el proveedor no recibe nada más.
Al reintentar un correo, lo que ya se resolvió dentro de él (otra factura del mismo correo) no se repite ni se responde dos veces. Sin seguimiento (si falla la base de datos) no se puede contar intentos, así que una caída se trata como error interno y no se reintenta.
## Modo prueba y modo real
`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 |
| `factura_ya_registrada` | La factura ya existe en SAP |
| `sin_pdf_xml` | El correo trae adjuntos, pero ninguno es PDF ni XML |
| `falta_xml` | Hay PDF y ningún XML |
| `sin_factura` | El XML no contiene una factura electrónica (tipo 01) |
## 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. (Solo se reintentan los correos cuando SAP está caído.)
- Los reintentos de SAP caído dependen de que el correo siga dentro de la ventana `--dias` del demonio (2 días por defecto).
- Un error que no es una caída (por ejemplo un HTTP 400 de SAP al crear el draft) no se reintenta: se avisa al equipo.
- La respuesta va a la dirección `from` del mensaje. Si alguien reenvía la factura de un proveedor, la respuesta le llega a quien reenvió.
- No se prueba contra el logo real ni contra Outlook de escritorio: hay que revisar los correos a mano.
+16 -6
View File
@@ -21,7 +21,9 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `observaciones` | JSON con los motivos de rechazo o el detalle del error |
| `doc_entry` | DocEntry del draft creado en SAP |
| `veces_recibido` | Cuántos correos distintos trajeron este comprobante |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida` u `omitida`, y su motivo |
| `notificacion_estado`, `notificacion_error`, `notificacion_fecha` | `pendiente`, `enviada`, `fallida`, `omitida` o `en_espera` (se avisó que el servicio está fuera de línea), y su motivo |
| `intentos` | Veces que se intentó procesar el comprobante con SAP fuera de línea (tope: `MAX_REINTENTOS`) |
| `notificacion_destinatario` | Direcciones a las que se envió **de verdad** (en modo prueba no son las del remitente) |
| `creado_en`, `actualizado_en` | Marcas de tiempo |
## Funciones
@@ -32,7 +34,9 @@ Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `d
| `registrar_correo(email_id)` | Igual, para un correo sin comprobante. Idempotente por correo. |
| `obtener(codigo)` | Devuelve el registro o `None`. |
| `actualizar_resultado(codigo, estado, observaciones=None, doc_entry=None)` | Guarda el resultado. `ValueError` si el estado no es válido, `LookupError` si no existe el código. |
| `registrar_notificacion(codigo, estado, error=None)` | Guarda el estado de la notificación. Mismas excepciones. |
| `registrar_notificacion(codigo, estado, error=None, destinatario=None)` | Guarda el estado de la notificación y a quién se envió. Mismas excepciones. |
| `registrar_intento(codigo)` | Suma un intento fallido por SAP fuera de línea y devuelve el total. |
| `contar_respuestas_correo(remitente, horas=24)` | Respuestas `enviada` a ese remitente en correos sin comprobante: el freno contra bucles de `respuesta_proveedor`. |
## Uso en el pipeline
@@ -44,14 +48,20 @@ En `pipeline._procesar_un_correo`, cada factura se registra al empezar (`_regist
| Precios no coinciden con SAP | `rechazado` | `[{"codigo": "precios_no_coinciden"}]` |
| Error al crear el draft | `error` | `[{"codigo": "error_sap", "detalle": "..."}]` |
`init_db` agrega las columnas `notificacion_destinatario` e `intentos` a una tabla creada antes sin ellas, sin tocar los datos.
El seguimiento **nunca impide procesar la factura**: si falla la base de datos, se registra un `WARNING` y el flujo continúa. Si el comprobante ya había llegado antes, se registra un `WARNING` con las veces recibidas y el estado anterior, pero **no se bloquea** la creación de otro draft.
## Estado actual y pendiente
## Observaciones guardadas
- Los `observaciones` guardados hoy son mínimos. Se reemplazarán por observaciones estructuradas (título, filas, cómo corregir) cuando se definan las validaciones de rechazo.
- `registrar_correo` y `registrar_notificacion` están implementados y probados, pero el pipeline todavía no los llama: los usarán las notificaciones al proveedor.
- Rechazo de una factura: la lista de observaciones completa (`codigo`, `titulo`, `filas`, `como_corregir`; ver `observaciones.py`).
- Error: `[{"codigo": "error_interno", "detalle": "..."}]`.
- SAP fuera de línea: `[{"codigo": "servicio_no_disponible", "detalle": "...", "intentos": n}]`.
- Correo sin comprobante: la observación del motivo (`sin_pdf_xml`, `falta_xml`, `sin_factura`).
Las notificaciones las registra `respuesta_proveedor` (`enviada`, `fallida` u `omitida` con su motivo).
## Notas
- `database_utilities.DB_NAME` es `"emails.db"`, una ruta **relativa al directorio de trabajo**. Si el demonio se lanza desde otra carpeta, usará (y creará) otra base y los códigos ya emitidos no se encontrarían. Hoy funciona porque se ejecuta desde la raíz del proyecto.
- `database_utilities.DB_NAME` es `emails.db` **junto al código** (ruta absoluta): no depende del directorio desde el que se lance el demonio, por ejemplo como servicio de Windows.
- Las pruebas redirigen `DB_NAME` a una base temporal (fixture automático en `tests/conftest.py`) para no escribir en `emails.db`.
+6
View File
@@ -37,6 +37,12 @@ def copiar_a_red(data: dict) -> None:
if not RUTA_RED_FACTURACION:
return
if not os.path.isabs(RUTA_RED_FACTURACION):
# Fuera de Windows una ruta UNC (\\\\servidor\\recurso) no es absoluta: makedirs la crearía como una carpeta
# local con ese nombre, y el draft apuntaría a archivos que no están en el recurso compartido.
log("⚠️ RUTA_RED_FACTURACION no es una ruta absoluta en este sistema operativo: no se copia a la red", 3,
severity=logging.WARNING)
return
ruc_emisor = data.get("ruc_emisor", "")
tipo = data.get("tipo", "")
+32 -1
View File
@@ -11,6 +11,35 @@ from pipeline_utils import log
# ============================================================================
def hay_error_de_consulta(resultados_val: list[dict]) -> str | None:
"""
Devuelve el detalle del primer error de consulta a SAP que dejó ``validate_prices``
(una guía cuya consulta lanzó excepción), o None si no hubo ninguno. Un error de
consulta es una falla nuestra: no debe confundirse con una guía inexistente.
"""
for resultado in resultados_val:
for item in resultado.get("guias_lt", []):
if item.get("error"):
return item["error"]
return None
def hana_disponible() -> bool:
"""
True si se puede abrir una conexión a SAP HANA. ``get_delivery_notes`` devuelve ``[]``
tanto si una guía no existe como si HANA está caído; antes de decirle a un proveedor
que una guía "no se encontró" conviene descartar que haya sido una caída.
"""
conexion = hana_manager.get_connection()
if conexion is None:
return False
try:
conexion.close()
except Exception:
pass
return True
def calcular_fecha_vencimiento(fecha_base: str, condicion_pago: dict | None) -> str:
"""
Calcula la fecha de vencimiento sumando a ``fecha_base`` los días de
@@ -339,9 +368,11 @@ def validate_prices(factura: dict) -> tuple[bool, list[dict]]:
guias_lt.append({"guia": guia, "line_total": suma_line_total, "warehouse_code": ", ".join(sorted(wh_set)) if wh_set else "A-CENTRA", "quantity": max_qty, "price_after_vat_sum": sum_pav, "zona":zona, "weight1": weight1, "draft_quantity": 0, "draft_price": 0.0})
all_wh.update(wh_set)
except sapb1_manager.SapNoDisponible:
raise # una caída de SAP no es un problema de la guía: lo decide quien llama
except Exception as ex:
log(f"⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}", 2, severity=logging.WARNING)
guias_lt.append({"guia": guia, "line_total": 0.0, "warehouse_code": "A-CENTRA", "weight1": 0.0, "quantity": 0.0, "price_after_vat_sum": 0.0, "zona": "", "draft_quantity": 0, "draft_price": 0.0})
guias_lt.append({"guia": guia, "line_total": 0.0, "warehouse_code": "A-CENTRA", "weight1": 0.0, "quantity": 0.0, "price_after_vat_sum": 0.0, "zona": "", "draft_quantity": 0, "draft_price": 0.0, "error": str(ex)})
all_ok = False
sap_total = sum(item["line_total"] for item in guias_lt)
+10 -5
View File
@@ -8,17 +8,22 @@ from dotenv import load_dotenv
import logger_manager
from config_validator import verificar_configuracion
from pipeline import run_pipeline
from pipeline_utils import version_del_proyecto
logger = logging.getLogger(__name__)
# ============================================================================
# (hora inicio, min inicio, hora fin, min fin, intervalo en segundos, modo). El fin no se incluye.
# Los bloques cubren las 24 horas sin huecos ni solapes (lo comprueba tests/test_horarios.py).
HORARIOS = [
(0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 60 segundos
(5, 0, 6, 30, 1800, "full"), # 05:00 - 06:30 -> cada 30 minutos
(6, 30, 13, 15, 300, "rapido"), # 06:30 - 13:15 -> cada 5 minutos
(13, 15, 14, 0, 1800, "full"), # 13:15 - 14:00 -> cada 30 minutos
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 17:30 -> cada 5 minutos
(18, 30, 21, 30, 3600, "full"), # 17:30 - 21:30 -> cada 60 minutos
(0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 5 minutos
(13, 15, 14, 0, 1800, "full"), # 13:15 - 14:00 -> cada 30 minutos
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 18:30 -> cada 5 minutos
(18, 30, 21, 30, 3600, "full"), # 18:30 - 21:30 -> cada 60 minutos
(21, 30, 24, 0, 3600, "full"), # 21:30 - 24:00 -> cada 60 minutos
]
# ============================================================================
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
@@ -130,7 +135,7 @@ if __name__ == "__main__":
logger_manager.setup_logging()
logger.info("=" * 60)
logger.info(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S}")
logger.info(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S} (versión {version_del_proyecto()})")
logger.info(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
logger.info("=" * 60)
+5 -1
View File
@@ -80,7 +80,8 @@ def build_notification_body(status: str, factura: dict, validation_table: str, v
del proceso SAP.
Args:
status: ``"created"`` (draft creado), ``"error"`` (error al
status: ``"created"`` (draft creado), ``"duplicate"`` (la factura ya
existe en SAP), ``"error"`` (error al
crear), o cualquier otro valor (validación falló, draft no
creado).
factura: Diccionario de la factura (se usa ``serie``,
@@ -111,6 +112,9 @@ def build_notification_body(status: str, factura: dict, validation_table: str, v
valref_section = f"<h3>Valor Referencial (XML vs SAP)</h3>{valor_referencial_table}"
if status == "created":
return f"<p>✅ Draft SAP B1 creado exitosamente.</p><p><b>DocEntry:</b> {kwargs.get('doc_entry')}</p>{base}<h3>Validación de precios</h3>{validation_table}{valref_section}"
elif status == "duplicate":
existente = kwargs.get("existente") or {}
return f"<p>⏭️ Draft NO creado: la factura ya existe en SAP ({existente.get('entidad')} {existente.get('DocEntry')}).</p>{base}"
elif status == "error":
return f"<p>❌ Error al crear Draft SAP B1.</p>{base}<p><b>Error:</b> {kwargs.get('error')}</p><h3>Validación de precios</h3>{validation_table}{valref_section}"
else:
+165
View File
@@ -0,0 +1,165 @@
"""
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 factura_ya_registrada(numero_comprobante: str) -> Observacion:
return Observacion(
"factura_ya_registrada", "Esta factura ya está registrada en nuestro sistema",
(("Comprobante", numero_comprobante),),
"No necesita enviarla otra vez. Si tiene dudas sobre su estado, escríbanos indicando el código de seguimiento.",
)
def cantidad_cero(numero_linea: int) -> Observacion:
return Observacion(
"cantidad_cero", "Una línea de la factura tiene cantidad cero", (("Línea", str(numero_linea)),),
"Corrija la cantidad de la línea y emita el comprobante nuevamente.",
)
# ============================================================================
# Motivos de rechazo de un correo sin comprobante válido
# ============================================================================
def sin_pdf_xml(archivos_recibidos: int) -> Observacion:
return Observacion(
"sin_pdf_xml", "No recibimos el XML ni el PDF de su comprobante",
(("Archivos recibidos", str(archivos_recibidos)),),
"Adjunte el XML y el PDF de la factura electrónica en el mismo correo.",
)
def falta_xml(pdfs_recibidos: int) -> Observacion:
return Observacion(
"falta_xml", "Falta el archivo XML de su comprobante", (("PDF recibidos", str(pdfs_recibidos)),),
"Adjunte también el XML de la factura electrónica: el PDF por sí solo no es suficiente.",
)
def sin_factura(documentos_recibidos: list[str]) -> Observacion:
return Observacion(
"sin_factura", "No encontramos una factura electrónica en su correo",
(("Documentos recibidos", ", ".join(documentos_recibidos) or "ninguno reconocible"),),
"Envíe el XML y el PDF de la factura electrónica. Los demás documentos no se registran por este medio.",
)
# ============================================================================
# Resultado de la validación de precios → observaciones
# ============================================================================
def _sin_datos_sap(item: dict) -> bool:
"""La guía no devolvió ningún dato de SAP (y no fue por un error de consulta)."""
return item.get("line_total") == 0 and item.get("quantity") == 0 and not item.get("error")
def desde_validacion(factura: dict, resultados_val: list[dict]) -> list[Observacion]:
"""
Observaciones de una factura a partir de los resultados de ``validate_prices``
(uno por línea). Una línea que no coincide da una observación por cada guía
que no se encontró y, si hay datos de SAP para alguna otra, una de importe.
"""
lineas = factura.get("lineas", [])
varias_lineas = len(lineas) > 1
observaciones: list[Observacion] = []
for i, resultado in enumerate(resultados_val):
if resultado.get("ok"):
continue
numero = i + 1
linea = lineas[i] if i < len(lineas) else {}
etiqueta = resultado.get("label", "")
if etiqueta == "Cantidad cero, sin validar":
observaciones.append(cantidad_cero(numero))
elif etiqueta == "Sin guía de referencia":
observaciones.append(linea_sin_guia(numero, linea.get("descripcion", "")))
else:
guias_lt = resultado.get("guias_lt", [])
no_encontradas = [it for it in guias_lt if _sin_datos_sap(it)]
observaciones += [guia_no_encontrada(_nombre_guia(it["guia"])) for it in no_encontradas]
if len(no_encontradas) < len(guias_lt):
observaciones.append(precio_no_coincide(
numero, float(linea.get("total_linea") or 0), float(resultado.get("sap_price") or 0),
[_nombre_guia(it["guia"]) for it in guias_lt], varias_lineas,
))
return observaciones
+29 -4
View File
@@ -52,11 +52,30 @@ def get_access_token() -> str:
correlation_id = result.get("correlation_id")
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})")
# ============================================================================
def es_mensaje_automatico(headers: list[dict] | None) -> bool:
"""
Indica si un mensaje fue generado automáticamente (respuesta automática,
rebote, lista de distribución…) a partir de ``internetMessageHeaders``.
No se debe responder a estos mensajes: se generarían bucles de correos.
"""
h = {(x.get("name") or "").lower(): (x.get("value") or "").strip().lower() for x in headers or []}
if h.get("auto-submitted", "no") not in ("", "no"):
return True
if "x-auto-response-suppress" in h or "x-autoreply" in h or "x-autorespond" in h:
return True
if h.get("precedence") in ("bulk", "junk", "list", "auto_reply"):
return True
return "report-type=" in h.get("content-type", "") # multipart/report: acuse o rebote de entrega
# ============================================================================
def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
"""
Obtiene los correos de una cuenta específica en un rango de fechas.
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach.
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject, has_attach
y es_automatico (True si es una respuesta automática o un rebote).
"""
headers = {
"Authorization": f"Bearer {token}",
@@ -73,7 +92,7 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
params = {
"$filter": filter_query,
"$select": "id,receivedDateTime,hasAttachments,subject,from",
"$select": "id,receivedDateTime,hasAttachments,subject,from,internetMessageHeaders",
"$top": 50
}
@@ -107,7 +126,8 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
"sender": sender,
"subject": msg.get("subject", ""),
"has_attach": msg.get("hasAttachments")
"has_attach": msg.get("hasAttachments"),
"es_automatico": es_mensaje_automatico(msg.get("internetMessageHeaders")),
})
url = data.get("@odata.nextLink")
@@ -153,7 +173,7 @@ def download_attachments(user_email: str, message_id: str, token: str, download_
return downloaded_files
# ============================================================================
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None) -> bool:
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None, bcc_recipients: list[str] | None = None) -> bool:
"""
Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF.
@@ -164,6 +184,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
:param html_body: Contenido del correo en formato HTML.
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
:param cc_recipients: Lista de correos electrónicos en copia (CC).
:param bcc_recipients: Lista de correos electrónicos en copia oculta (BCC): no los ve el resto de destinatarios.
:return: True si se envió correctamente, False en caso contrario.
"""
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
@@ -192,6 +213,9 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
}
})
# Construir lista de destinatarios en copia oculta (BCC) para Graph API
bcc_payload = [{"emailAddress": {"address": email}} for email in (bcc_recipients or [])]
# Construir lista de adjuntos
attachments_payload = []
if attachment_paths:
@@ -229,6 +253,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
},
"toRecipients": recipients_payload,
"ccRecipients": cc_payload,
"bccRecipients": bcc_payload,
"attachments": attachments_payload
},
"saveToSentItems": "true"
+3 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
import sunat_schema
import pdf_utilities
from pipeline_utils import log
from pipeline_utils import log, ruta_carpeta
# ============================================================================
@@ -34,6 +34,7 @@ def guardar_resultado(documentos: list[dict], datos_pdf: list[dict], output_fold
"""
if not documentos:
return []
output_folder = ruta_carpeta(output_folder) # relativa -> junto al código; absoluta -> tal cual
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
@@ -154,6 +155,7 @@ def guardar_draft_json(draft: dict, serie: str, numero: str, output_folder: str)
guardar_draft_json(draft, "F002", "486", output_folder)
# → Crea draft_sapb1_F002-486_2026-07-08_12-00-00.json
"""
output_folder = ruta_carpeta(output_folder)
if not os.path.exists(output_folder):
os.makedirs(output_folder, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+220 -55
View File
@@ -20,12 +20,17 @@ import documentos as docs
import persistencia
import draft_sap
import notificaciones
import observaciones
import respuesta_proveedor
import sapb1_manager
import seguimiento
from pipeline_utils import log
logger = logging.getLogger(__name__)
# Veces que se reintenta un comprobante mientras SAP está fuera de línea (un intento por ciclo del demonio)
MAX_REINTENTOS = 24
# ============================================================================
# SEGUIMIENTO (códigos y resultado por comprobante)
@@ -61,6 +66,201 @@ def _registrar_resultado(registro: dict | None, estado: str, **kwargs) -> None:
log(f"⚠️ No se pudo guardar el resultado en seguimiento: {ex}", severity=logging.WARNING)
def _registrar_correo(email: dict) -> dict | None:
"""Como ``_registrar_comprobante``, para un correo sin comprobante identificable."""
try:
registro = seguimiento.registrar_correo(email["id"])
except Exception as ex:
log(f"⚠️ No se pudo registrar el correo en seguimiento: {ex}", severity=logging.WARNING)
return None
log(f"🔖 Código de seguimiento: {registro['codigo_seguimiento']}")
return registro
def _rechazar_correo(email: dict, token: str, email_addr: str, lista_obs: list) -> None:
"""
Rechaza un correo que no trae ningún comprobante válido: registra el motivo y
responde al remitente con la plantilla de rechazo (si las protecciones lo permiten).
"""
log(f"❌ Correo rechazado: {lista_obs[0].titulo}", severity=logging.WARNING)
registro = _registrar_correo(email)
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
if not registro or not token:
return
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_correo(email, registro, lista_obs), registro, sin_comprobante=True,
)
def _contar_intento(registro: dict | None) -> int | None:
"""Suma un intento al comprobante (SAP fuera de línea). None si no se puede llevar la cuenta."""
if not registro:
return None
try:
return seguimiento.registrar_intento(registro["codigo_seguimiento"])
except Exception as ex:
log(f"⚠️ No se pudo contar el reintento en seguimiento: {ex}", severity=logging.WARNING)
return None
def _procesar_factura(email: dict, token: str, email_addr: str, factura: dict, output_folder: str) -> bool:
"""
Valida una factura contra SAP, crea el draft si corresponde y notifica el resultado.
Resultados posibles: ``aceptado`` (draft creado), ``rechazado`` (precios, guías o factura ya
registrada en SAP), ``error`` (falla nuestra: solo se avisa al equipo) o ``fuera_de_linea``
(SAP o HANA no responden: se avisa una vez al proveedor y se reintenta).
Returns:
True si hay que reintentar el correo en el próximo ciclo (SAP fuera de línea).
"""
serie = factura.get("serie", "?")
numero = factura.get("numero", "?")
log(f"📄 Factura: {serie}-{numero}")
registro = _registrar_comprobante(email, factura)
# Al reintentar un correo, lo que ya se resolvió no se repite (ni se responde dos veces)
if registro and not registro["reenvio"] and registro["estado"] in ("aceptado", "rechazado"):
log(f"⏭️ {serie}-{numero} ya se resolvió al procesar este correo ({registro['estado']}): no se repite")
return False
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
estado = None # aceptado | rechazado | error | fuera_de_linea
detalle_error = None
existente = None
todas_coinciden = False
lista_obs: list = []
resultados_val: list = []
draft: dict = {}
doc_entry = None
validation_table = valor_referencial_table = ""
try:
# 11.5 ¿La factura ya existe en SAP (factura de compra o draft)?
existente = sapb1_manager.buscar_factura_existente(
factura.get("ruc_emisor") or "", factura.get("tipo", "01"), serie, numero,
)
if existente:
lista_obs = [observaciones.factura_ya_registrada(f"{serie}-{numero}")]
else:
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
validation_table = notificaciones.build_validation_html(resultados_val)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
draft = draft_sap.build_sap_draft(factura, resultados_val)
# 14. Guardar draft JSON (siempre, para depuración)
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
detalle_error = draft_sap.hay_error_de_consulta(resultados_val)
if not todas_coinciden:
lista_obs = observaciones.desde_validacion(factura, resultados_val)
# "guía no encontrada" también es lo que se ve si HANA está caído: no culpar al proveedor
if (not detalle_error and any(o.codigo == "guia_no_encontrada" for o in lista_obs)
and not draft_sap.hana_disponible()):
raise sapb1_manager.SapNoDisponible("SAP HANA no responde: no se puede confirmar que las guías no existan")
except sapb1_manager.SapNoDisponible as ex:
estado, detalle_error = "fuera_de_linea", str(ex)
except Exception as ex:
detalle_error = f"{type(ex).__name__}: {ex}"
# 15. Crear el draft en SAP o decidir el resultado
if estado is None:
if detalle_error:
estado = "error"
elif existente or not todas_coinciden:
estado = "rechazado"
else:
try:
doc_entry = sapb1_manager.create_draft(draft).get("DocEntry")
estado = "aceptado"
except sapb1_manager.SapNoDisponible as ex:
estado, detalle_error = "fuera_de_linea", str(ex)
except Exception as ex:
estado, detalle_error = "error", str(ex)
if estado == "aceptado":
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
elif estado == "rechazado":
motivo = "la factura ya existe en SAP" if existente else "validación falló"
log(f"⏭️ Draft NO creado: {serie}-{numero} — {motivo}")
if not (existente and registro and registro["estado"] == "aceptado"): # no pisar el registro de la aceptación original
_registrar_resultado(registro, "rechazado", observaciones=[o.a_dict() for o in lista_obs])
html_body = notificaciones.build_notification_body(
"duplicate" if existente else "skipped", factura, validation_table, valor_referencial_table, existente=existente)
else: # error | fuera_de_linea
log(f"❌ {'SAP fuera de línea' if estado == 'fuera_de_linea' else 'Error'} en {serie}-{numero}: {detalle_error}",
severity=logging.ERROR)
html_body = notificaciones.build_notification_body("error", factura, validation_table, valor_referencial_table, error=detalle_error)
# SAP fuera de línea: se cuenta el intento y se avisa solo la primera vez
reintentar = False
primera_vez = agotado = False
if estado == "fuera_de_linea":
intentos = _contar_intento(registro)
if intentos is None: # sin seguimiento no se puede reintentar con seguridad
estado = "error"
else:
primera_vez, agotado = intentos == 1, intentos >= MAX_REINTENTOS
reintentar = not agotado
_registrar_resultado(registro, "error", observaciones=[
{"codigo": "servicio_no_disponible", "detalle": detalle_error, "intentos": intentos}])
log(f"🔁 Intento {intentos} de {MAX_REINTENTOS}" + (": se agotaron los reintentos" if agotado else ""),
severity=logging.WARNING)
if estado == "error":
_registrar_resultado(registro, "error", observaciones=[{"codigo": "error_interno", "detalle": detalle_error}])
# 16. Notificar: correo técnico solo al equipo; respuesta con plantilla al remitente
if not token:
return reintentar
asunto_tecnico = f"Draft SAP B1 - {serie}-{numero}"
if estado == "fuera_de_linea":
asunto_tecnico = ("Reintentos agotados - " if agotado else "SAP fuera de línea - ") + asunto_tecnico
if estado != "fuera_de_linea" or primera_vez or agotado: # no repetir el aviso en cada reintento
respuesta_proveedor.enviar_a_equipo(
token, email_addr, asunto_tecnico,
respuesta_proveedor.encabezado_tecnico(email, registro) + html_body, adjuntos_email,
)
if registro is None:
log("⚠️ Sin código de seguimiento: no se responde al proveedor", severity=logging.WARNING)
elif estado == "aceptado":
respuesta_proveedor.responder(
token, email_addr, email, "aceptado",
respuesta_proveedor.contexto_aceptado(email, registro, factura, resultados_val, draft), registro,
)
elif estado == "rechazado":
respuesta_proveedor.responder(
token, email_addr, email, "rechazado",
respuesta_proveedor.contexto_rechazo_factura(email, registro, factura, resultados_val, lista_obs), registro,
)
elif estado == "fuera_de_linea":
if primera_vez:
respuesta_proveedor.responder(
token, email_addr, email, "fuera-de-linea",
respuesta_proveedor.contexto_fuera_de_linea(email, registro, factura), registro, estado_exito="en_espera",
)
elif agotado:
respuesta_proveedor.omitir_respuesta(registro, "se agotaron los reintentos: lo revisa el equipo")
else:
respuesta_proveedor.omitir_respuesta(registro, "error interno: se avisó solo al equipo")
return reintentar
# ============================================================================
# PASO 1: Autenticación Microsoft Graph
# ============================================================================
@@ -146,14 +346,16 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
# 3. Descargar adjuntos
lista_adjuntos = adjuntos.descargar_adjuntos(email, email_addr, token, download_folder)
if not lista_adjuntos:
log("⏭️ Correo sin adjuntos")
log("⏭️ Correo sin adjuntos") # no se responde: no hubo intento de enviar un comprobante
return
n_recibidos = len(lista_adjuntos)
# 4. Descomprimir ZIPs
email_utilities.descomprimir_adjuntos([email], download_folder)
# 5. Filtrar PDF/XML y limpiar si no hay válidos
if not adjuntos.filtrar_y_limpiar(email, download_folder):
_rechazar_correo(email, token, email_addr, [observaciones.sin_pdf_xml(n_recibidos)])
return
# 6. Procesar PDFs
@@ -163,6 +365,11 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
documentos = docs.procesar_xmls(email, download_folder, pdf_cache)
if not documentos:
log("⏭️ No se extrajeron documentos XML")
if any(a.lower().endswith(".xml") for a in email["adjuntos"]):
motivo = observaciones.sin_factura([])
else:
motivo = observaciones.falta_xml(sum(a.lower().endswith(".pdf") for a in email["adjuntos"]))
_rechazar_correo(email, token, email_addr, [motivo])
return
# 8. Completar guías faltantes desde PDF
@@ -181,64 +388,22 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
facturas = [d for d in documentos if d.get("tipo") == "01"]
if not facturas:
log("⏭️ No hay facturas (tipo 01) para SAP")
_rechazar_correo(email, token, email_addr,
[observaciones.sin_factura(sorted({d.get("tipo_descripcion", "?") for d in documentos}))])
return
reintentar = False
for factura in facturas:
serie = factura.get("serie", "?")
numero = factura.get("numero", "?")
if _procesar_factura(email, token, email_addr, factura, output_folder):
reintentar = True
log(f"📄 Factura: {serie}-{numero}")
registro = _registrar_comprobante(email, factura)
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = draft_sap.validate_prices(factura)
validation_table = notificaciones.build_validation_html(resultados_val)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = draft_sap.obtener_valor_referencial_sap(factura)
valor_referencial_table = notificaciones.build_valor_referencial_html(factura, guias_valor_ref)
# 13. Construir draft (usa LineTotal de los DNs, no del XML)
draft = draft_sap.build_sap_draft(factura, resultados_val)
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
# 14. Guardar draft JSON (siempre, para depuración)
persistencia.guardar_draft_json(draft, serie, numero, output_folder)
# 15. Crear draft en SAP o generar HTML
if todas_coinciden:
try:
sap_result = sapb1_manager.create_draft(draft)
doc_entry = sap_result.get("DocEntry")
log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
_registrar_resultado(registro, "aceptado", doc_entry=doc_entry)
html_body = notificaciones.build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
except Exception as ex:
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:
log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
_registrar_resultado(registro, "rechazado", observaciones=[{"codigo": "precios_no_coinciden"}])
html_body = notificaciones.build_notification_body("skipped", factura, validation_table, valor_referencial_table)
# 16. Enviar notificación al remitente original, con copia al equipo
if token:
destinatario = email.get("sender")
if destinatario and destinatario != "Desconocido":
outlook_utilities.send_email(
token, email_addr,
[destinatario],
f"Draft SAP B1 - {serie}-{numero}",
html_body, adjuntos_email,
cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
)
else:
log("⚠️ No se pudo determinar el remitente, notificación no enviada", severity=logging.WARNING)
if reintentar:
# SAP fuera de línea: se olvida el correo para que el próximo ciclo lo procese de nuevo
try:
database_utilities.delete_email_by_id(email["id"])
log("🔁 Correo en cola: se reintentará en el próximo ciclo")
except Exception as ex:
log(f"⚠️ No se pudo dejar el correo en cola: {ex}", severity=logging.WARNING)
# ============================================================================
+11
View File
@@ -2,6 +2,7 @@
import os
import logging
import tomllib
logger = logging.getLogger("pipeline")
@@ -25,3 +26,13 @@ def log(msg: str, level: int = 1, last: bool = False, severity: int = logging.IN
branch = " └── " if last else " ├── "
pfx = {1: branch, 2: f" │ {branch}", 3: f" │ │ {branch}", 4: f" │ │ │ {branch}"}.get(level, branch)
logger.log(severity, f"{pfx}{msg}")
def version_del_proyecto() -> str:
"""Versión de ``pyproject.toml`` (por ejemplo ``1.2.0``), o ``desconocida`` si no se puede leer."""
try:
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "pyproject.toml"), "rb") as f:
return tomllib.load(f)["project"]["version"]
except (OSError, KeyError, tomllib.TOMLDecodeError):
return "desconocida"
+297
View File
@@ -0,0 +1,297 @@
{
"aceptado_con_pago": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Aceptado · Factura F002-486 · ABR-261019-00042",
"preheader": "Su factura F002-486 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00042",
"fecha_estimada_pago": "18/11/2026",
"guias": [
{
"guia": "T001-127773",
"importe_factura": "S/ 624.38",
"conforme": true
},
{
"guia": "T001-127774",
"importe_factura": "S/ 311.20",
"conforme": true
},
{
"guia": "T001-127775",
"importe_factura": "S/ 1,250.00",
"conforme": true
}
]
}
},
"aceptado_sin_pago": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "SERVICIOS LOGÍSTICOS DEL SUR S.A.",
"proveedor_ruc": "20111111111",
"asunto": "Aceptado · Factura E001-2403 · ABR-261019-00043",
"preheader": "Su factura E001-2403 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "E001-2403",
"codigo_seguimiento": "ABR-261019-00043",
"fecha_estimada_pago": null,
"guias": [
{
"guia": "T001-129352",
"importe_factura": "S/ 828.73",
"conforme": true
}
]
}
},
"aceptado_muchas_guias": {
"plantilla": "aceptado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Aceptado · Factura F004-5989 · ABR-261019-00044",
"preheader": "Su factura F004-5989 pasó todas las validaciones. No necesita hacer nada más.",
"comprobante_numero": "F004-5989",
"codigo_seguimiento": "ABR-261019-00044",
"fecha_estimada_pago": "30/11/2026",
"guias": [
{
"guia": "T001-130000",
"importe_factura": "S/ 137.50",
"conforme": true
},
{
"guia": "T001-130001",
"importe_factura": "S/ 275.00",
"conforme": true
},
{
"guia": "T001-130002",
"importe_factura": "S/ 412.50",
"conforme": true
},
{
"guia": "T001-130003",
"importe_factura": "S/ 550.00",
"conforme": true
},
{
"guia": "T001-130004",
"importe_factura": "S/ 687.50",
"conforme": true
},
{
"guia": "T001-130005",
"importe_factura": "S/ 825.00",
"conforme": true
},
{
"guia": "T001-130006",
"importe_factura": "S/ 962.50",
"conforme": true
},
{
"guia": "T001-130007",
"importe_factura": "S/ 1,100.00",
"conforme": true
},
{
"guia": "T001-130008",
"importe_factura": "S/ 1,237.50",
"conforme": true
},
{
"guia": "T001-130009",
"importe_factura": "S/ 1,375.00",
"conforme": true
},
{
"guia": "T001-130010",
"importe_factura": "S/ 1,512.50",
"conforme": true
},
{
"guia": "T001-130011",
"importe_factura": "S/ 1,650.00",
"conforme": true
}
]
}
},
"rechazado_dos_observaciones": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "Rechazado · Factura F002-486 · ABR-261019-00045",
"preheader": "Tiene 2 observaciones. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00045",
"guias": [
{
"guia": "T001-127773",
"importe_factura": "S/ 624.38",
"conforme": false
},
{
"guia": "T001-127774",
"importe_factura": "S/ 311.20",
"conforme": true
}
],
"observaciones_texto": "2 observaciones",
"observaciones": [
{
"numero": 1,
"titulo": "El importe no coincide con nuestro registro",
"filas": [
{
"etiqueta": "Importe en su factura",
"valor": "S/ 624.38"
},
{
"etiqueta": "Importe esperado",
"valor": "S/ 600.00"
}
],
"como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto."
},
{
"numero": 2,
"titulo": "No encontramos la guía T001-128912",
"filas": [
{
"etiqueta": "Guía",
"valor": "T001-128912"
}
],
"como_corregir": "Verifique que la guía esté registrada y que la serie y el número estén bien escritos."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": true
}
},
"rechazado_una_observacion": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES \"EL RÁPIDO\" <Lima> E.I.R.L.",
"proveedor_ruc": "10060028686",
"asunto": "Rechazado · Factura F002-2450 · ABR-261019-00046",
"preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": "F002-2450",
"codigo_seguimiento": "ABR-261019-00046",
"guias": [
{
"guia": "T001-128912",
"importe_factura": "S/ 624.38",
"conforme": false
}
],
"observaciones_texto": "1 observación",
"observaciones": [
{
"numero": 1,
"titulo": "El importe no coincide con nuestro registro",
"filas": [
{
"etiqueta": "Importe en su factura",
"valor": "S/ 624.38"
},
{
"etiqueta": "Importe esperado",
"valor": "S/ 600.00"
}
],
"como_corregir": "Revise el precio acordado para el servicio y emita el comprobante con el importe correcto."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": true
}
},
"rechazado_sin_comprobante": {
"plantilla": "rechazado",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "comprobante",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "proveedor@ejemplo.com",
"proveedor_ruc": null,
"asunto": "Rechazado · Comprobante no recibido · ABR-261019-00047",
"preheader": "Tiene 1 observación. Corríjalas y envíenos el comprobante nuevamente.",
"comprobante_numero": null,
"codigo_seguimiento": "ABR-261019-00047",
"guias": [],
"observaciones_texto": "1 observación",
"observaciones": [
{
"numero": 1,
"titulo": "El correo no trae el XML ni el PDF del comprobante",
"filas": [
{
"etiqueta": "Archivos recibidos",
"valor": "0"
}
],
"como_corregir": "Adjunte el XML y el PDF del comprobante en el mismo correo."
}
],
"envio_correo": "comprobantes@ejemplo.com",
"tiene_guias": false
}
},
"fuera_de_linea": {
"plantilla": "fuera-de-linea",
"contexto": {
"logo_url": "",
"comprobante_tipo_corto": "factura",
"comprobante_tipo": "Factura electrónica",
"recepcion_fecha": "19/10/2026",
"recepcion_hora": "10:35",
"contacto_correo": "contacto@ejemplo.com",
"proveedor_razon_social": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"proveedor_ruc": "20537929520",
"asunto": "En cola · Factura F002-486 · ABR-261019-00048",
"preheader": "Su factura F002-486 quedó en cola: nuestro sistema está fuera de línea. No necesita reenviarla.",
"comprobante_numero": "F002-486",
"codigo_seguimiento": "ABR-261019-00048"
}
}
}
+139
View File
@@ -0,0 +1,139 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#E8F3EC" style="background:#E8F3EC;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#1D7A46;"><span aria-hidden="true" style="color:#1D7A46;">&#10003;&nbsp;</span><span style="color:#1D7A46;">ACEPTADO</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">Registramos su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Pasó todas las validaciones. <strong>No necesita hacer nada más.</strong></p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
{{#fecha_estimada_pago}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Pago estimado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{fecha_estimada_pago}}</td>
</tr>
{{/fecha_estimada_pago}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#1D7A46;">Aceptado</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Guías revisadas</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #EDEDED;border-collapse:collapse;">
<thead><tr bgcolor="#F7F7F7" style="background:#F7F7F7;">
<th scope="col" align="left" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">GUÍA</th>
<th scope="col" align="right" class="hide-sm" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">IMPORTE</th>
<th scope="col" align="right" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">RESULTADO</th>
</tr></thead>
<tbody>
{{#guias}}
<tr>
<td style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{guia}}</td>
<td align="right" class="hide-sm" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{importe_factura}}</td>
<td align="right" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;font-weight:700;">{{#conforme}}<span style="color:#1D7A46;"><span aria-hidden="true">&#10003;&nbsp;</span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">&#10005;&nbsp;</span>No coincide</span>{{/conforme}}</td>
</tr>
{{/guias}}
</tbody>
</table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Qué sigue</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">El pago sigue según las condiciones acordadas.<br>Conserve el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong> para cualquier consulta.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#FFF4E0" style="background:#FFF4E0;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#9A5B00;"><span aria-hidden="true" style="color:#9A5B00;">&#9679;&nbsp;</span><span style="color:#9A5B00;">EN COLA</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">Recibimos su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Nuestro sistema está <strong>temporalmente fuera de línea</strong> y no pudimos validarla todavía. Quedó en cola y la procesaremos automáticamente. <strong>No necesita reenviarla.</strong></p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#9A5B00;">En cola · servicio fuera de línea</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Qué sigue</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Le enviaremos la respuesta (aceptado o rechazado) a este mismo correo apenas se procese.<br>Conserve el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong> para cualquier consulta.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="es" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>{{asunto}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<style>table,td,th,div,p,a,span,h1,h2,h3,li{font-family:Arial,Helvetica,sans-serif !important;}</style>
<![endif]-->
<style>
body{margin:0;padding:0;width:100%!important;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
table{border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt;}
img{border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;}
a{color:#B84800;}
a[x-apple-data-detectors]{color:inherit!important;text-decoration:none!important;}
@media screen and (max-width:620px){
.px{padding-left:20px!important;padding-right:20px!important;}
.outer{padding:0!important;}
.card{border-radius:0!important;}
.stack{display:block!important;width:100%!important;max-width:100%!important;}
.stack-gap{padding-top:14px!important;}
.h1{font-size:22px!important;mso-line-height-rule:exactly;line-height:30px!important;}
.hide-sm{display:none!important;}
.al-sm{text-align:left!important;}
}
</style>
</head>
<body style="margin:0;padding:0;background:#F5F5F5;" bgcolor="#F5F5F5">
<div style="display:none;font-size:1px;mso-line-height-rule:exactly;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;color:transparent;">{{preheader}}&#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; &#8199;&#65279;&#847; </div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F5F5F5" style="background:#F5F5F5;">
<tr><td align="center" class="outer" style="padding:28px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" align="center"><tr><td><![endif]-->
<table role="presentation" class="card" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#FFFFFF" style="width:100%;max-width:600px;background:#FFFFFF;">
<tr><td height="4" bgcolor="#FF6900" style="height:4px;mso-line-height-rule:exactly;line-height:4px;font-size:4px;mso-line-height-rule:exactly;background:#FF6900;">&nbsp;</td></tr>
<tr><td class="px" style="padding:26px 32px 22px;border-bottom:1px solid #EDEDED;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td valign="middle"><img src="{{logo_url}}" width="117" height="28" alt="Aberio" style="display:block;width:117px;height:28px;color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:20px;font-weight:700;"></td>
<td valign="middle" align="right" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;color:#666666;">Recepción de comprobantes</td>
</tr></table>
</td></tr>
<tr><td class="px" style="padding:32px 32px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td bgcolor="#FDECE9" style="background:#FDECE9;border-radius:3px;padding:5px 11px;mso-padding-alt:4px 11px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#C0291C;"><span aria-hidden="true" style="color:#C0291C;">&#10005;&nbsp;</span><span style="color:#C0291C;">RECHAZADO</span></td></tr></table>
<h1 class="h1" style="margin:16px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:24px;mso-line-height-rule:exactly;line-height:32px;font-weight:700;color:#1A1A1A;">No pudimos registrar su {{comprobante_tipo_corto}}</h1>
<p style="margin:8px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:23px;color:#282828;">Tiene <strong>{{observaciones_texto}}</strong>. Corríjalas y envíenos el comprobante nuevamente.</p>
</td></tr>
<tr><td class="px" style="padding:24px 32px 0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:16px 20px 10px;">
<p style="margin:0 0 4px;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#666666;">DATOS DEL COMPROBANTE</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Proveedor</td>
<td valign="top" style="padding:8px 0;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{proveedor_razon_social}}{{#proveedor_ruc}}<br><span style="font-weight:400;font-size:13px;color:#666666;">RUC {{proveedor_ruc}}</span>{{/proveedor_ruc}}</td>
</tr>
{{#comprobante_numero}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Comprobante</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{comprobante_tipo}} <span style="white-space:nowrap;">{{comprobante_numero}}</span></td>
</tr>
{{/comprobante_numero}}
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Recibido</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{recepcion_fecha}}, {{recepcion_hora}} h</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Código de seguimiento</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;">{{codigo_seguimiento}}</td>
</tr>
<tr>
<td valign="top" width="40%" style="width:40%;padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">Estado</td>
<td valign="top" style="padding:8px 0;border-top:1px solid #EDEDED;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;font-weight:700;"><span style="color:#C0291C;">Rechazado · {{observaciones_texto}}</span></td>
</tr>
</table>
</td></tr></table>
</td></tr>
{{#tiene_guias}}
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Guías revisadas</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #EDEDED;border-collapse:collapse;">
<thead><tr bgcolor="#F7F7F7" style="background:#F7F7F7;">
<th scope="col" align="left" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">GUÍA</th>
<th scope="col" align="right" class="hide-sm" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">IMPORTE</th>
<th scope="col" align="right" style="padding:9px 12px;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.06em;color:#666666;border-bottom:1px solid #EDEDED;">RESULTADO</th>
</tr></thead>
<tbody>
{{#guias}}
<tr>
<td style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{guia}}</td>
<td align="right" class="hide-sm" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;white-space:nowrap;">{{importe_factura}}</td>
<td align="right" style="padding:10px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#282828;border-bottom:1px solid #EDEDED;font-weight:700;">{{#conforme}}<span style="color:#1D7A46;"><span aria-hidden="true">&#10003;&nbsp;</span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">&#10005;&nbsp;</span>No coincide</span>{{/conforme}}</td>
</tr>
{{/guias}}
</tbody>
</table>
</td></tr>
{{/tiene_guias}}
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Observaciones</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{#observaciones}}
<tr><td style="padding:0 0 18px;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;mso-line-height-rule:exactly;line-height:16px;font-weight:700;letter-spacing:.08em;color:#C0291C;">OBSERVACIÓN {{numero}}</p>
<p style="margin:2px 0 6px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">{{titulo}}</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="border-left:2px solid #C0291C;padding:0;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
{{#filas}}
<tr>
<td style="padding:2px 0 2px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;color:#666666;">{{etiqueta}}</td>
<td align="right" style="padding:2px 0 2px 12px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;white-space:nowrap;">{{valor}}</td>
</tr>
{{/filas}}
</table>
</td></tr></table>
<p style="margin:6px 0 0;font-family:Arial,Helvetica,sans-serif;font-size:13px;mso-line-height-rule:exactly;line-height:19px;color:#666666;">{{como_corregir}}</p>
</td></tr>
{{/observaciones}}
</table>
</td></tr>
<tr><td class="px" style="padding:28px 32px 10px;">
<h2 style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:16px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#1A1A1A;">Para continuar</h2>
</td></tr>
<tr><td class="px" style="padding:0 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td valign="top" width="32" style="width:32px;padding:0 0 10px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td width="22" height="22" align="center" bgcolor="#FF6900" style="width:22px;height:22px;border-radius:11px;background:#FF6900;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">1</td></tr></table>
</td>
<td valign="top" style="padding:1px 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Emita el comprobante corregido. Si corresponde, anule el anterior con una nota de crédito.</td>
</tr>
<tr>
<td valign="top" width="32" style="width:32px;padding:0 0 10px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;"><tr><td width="22" height="22" align="center" bgcolor="#FF6900" style="width:22px;height:22px;border-radius:11px;background:#FF6900;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:22px;font-weight:700;color:#282828;">2</td></tr></table>
</td>
<td valign="top" style="padding:1px 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;">Envíe el XML y PDF a <a href="mailto:{{envio_correo}}" style="color:#B84800;font-weight:700;">{{envio_correo}}</a> con el código <strong style="white-space:nowrap;">{{codigo_seguimiento}}</strong>.</td>
</tr>
</table>
</td></tr>
<tr><td class="px" style="padding:24px 32px 32px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#F7F7F7" style="background:#F7F7F7;">
<tr><td style="padding:18px 20px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td class="stack" valign="middle" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;mso-line-height-rule:exactly;line-height:21px;color:#282828;padding-right:16px;"><strong>¿Tiene dudas?</strong><br><span style="color:#666666;">Escríbanos con el código de seguimiento.</span></td>
<td class="stack stack-gap" valign="middle" style="width:1%;white-space:nowrap;"><!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="height:44px;v-text-anchor:middle;width:180px;" arcsize="9%" stroke="f" fillcolor="#FF6900">
<w:anchorlock/>
<center style="color:#282828;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;">Escribir a Aberio</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!--><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
<td bgcolor="#FF6900" style="background:#FF6900;border-radius:4px;mso-padding-alt:12px 22px;">
<a href="mailto:{{contacto_correo}}?subject=Consulta%20{{comprobante_numero}}%20{{codigo_seguimiento}}" style="display:inline-block;padding:12px 22px;font-family:Arial,Helvetica,sans-serif;font-size:15px;mso-line-height-rule:exactly;line-height:20px;font-weight:700;color:#282828;text-decoration:none;border-radius:4px;"><span style="color:#282828;">Escribir a Aberio</span></a>
</td></tr></table><!--<![endif]--></td>
</tr></table>
</td></tr></table>
</td></tr>
<tr><td class="px" style="padding:18px 32px 22px;border-top:1px solid #EDEDED;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:12px;mso-line-height-rule:exactly;line-height:18px;color:#666666;"><strong style="color:#282828;">A. Berio y Cía S.A.C.</strong> · RUC 20100029741 · Av. 26 de Noviembre 2473, Villa María del Triunfo, Lima</p>
</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>
+123
View File
@@ -0,0 +1,123 @@
"""
Renderizado de las plantillas HTML de correo (``plantilla/plantilla-*.html``).
Las plantillas usan un subconjunto de Mustache:
{{variable}} valor, con escape de HTML
{{#nombre}}...{{/nombre}} sección: se repite por cada elemento de una lista,
o se muestra una vez si el valor es verdadero
{{^nombre}}...{{/nombre}} sección invertida: se muestra si el valor es falso o vacío
El renderizado es **estricto**: toda variable o sección debe existir en el
contexto (usa ``None``, ``False`` o ``[]`` para ocultar algo). Así un dato que
falta provoca un error al renderizar, en vez de un correo con huecos.
No soporta ``{{{triple}}}``, parciales, comentarios ni nombres con punto; no se
usan en las plantillas.
"""
import html
import os
import re
CARPETA_PLANTILLAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla")
_ETIQUETA = re.compile(r"\{\{\s*([#^/]?)\s*([A-Za-z_]\w*)\s*\}\}")
class PlantillaError(ValueError):
"""La plantilla está mal formada o el contexto no tiene algún dato que necesita."""
# ============================================================================
def _analizar(texto: str) -> list:
"""Convierte el texto en un árbol de nodos: ``("texto", s)``, ``("var", nombre)`` o ``("sec", nombre, hijos, invertida)``."""
raiz: list = []
pila: list[tuple[str, list, bool]] = [] # (nombre, hijos, invertida) de las secciones abiertas
actual = raiz
posicion = 0
for m in _ETIQUETA.finditer(texto):
if m.start() > posicion:
actual.append(("texto", texto[posicion:m.start()]))
posicion = m.end()
marca, nombre = m.group(1), m.group(2)
if marca == "":
actual.append(("var", nombre))
elif marca in ("#", "^"):
hijos: list = []
actual.append(("sec", nombre, hijos, marca == "^"))
pila.append((nombre, actual, marca == "^"))
actual = hijos
else: # cierre
linea = texto.count("\n", 0, m.start()) + 1
if not pila or pila[-1][0] != nombre:
raise PlantillaError(f"Línea {linea}: '{{{{/{nombre}}}}}' no corresponde a ninguna sección abierta")
actual = pila.pop()[1]
if pila:
raise PlantillaError(f"La sección '{pila[-1][0]}' no se cierra")
if posicion < len(texto):
actual.append(("texto", texto[posicion:]))
return raiz
def _buscar(nombre: str, pila: list):
for marco in reversed(pila):
if isinstance(marco, dict) and nombre in marco:
return marco[nombre]
raise PlantillaError(f"Falta '{nombre}' en el contexto de la plantilla")
def _generar(nodos: list, pila: list) -> str:
salida: list[str] = []
for nodo in nodos:
tipo = nodo[0]
if tipo == "texto":
salida.append(nodo[1])
elif tipo == "var":
valor = _buscar(nodo[1], pila)
salida.append("" if valor is None else html.escape(str(valor), quote=True))
else:
_, nombre, hijos, invertida = nodo
valor = _buscar(nombre, pila)
if invertida:
if not valor:
salida.append(_generar(hijos, pila))
elif isinstance(valor, list):
for elemento in valor:
salida.append(_generar(hijos, pila + [elemento]))
elif valor:
salida.append(_generar(hijos, pila + [valor] if isinstance(valor, dict) else pila))
return "".join(salida)
# ============================================================================
def renderizar(texto: str, contexto: dict) -> str:
"""Renderiza ``texto`` (plantilla Mustache) con ``contexto``. Lanza ``PlantillaError`` si falta algún dato."""
return _generar(_analizar(texto), [contexto])
def nombres_usados(texto: str) -> set[str]:
"""Todos los nombres (variables y secciones) que usa la plantilla."""
def recorrer(nodos):
for n in nodos:
if n[0] == "var":
yield n[1]
elif n[0] == "sec":
yield n[1]
yield from recorrer(n[2])
return set(recorrer(_analizar(texto)))
def cargar_plantilla(nombre: str) -> str:
"""Lee ``plantilla/plantilla-<nombre>.html`` (por ejemplo ``aceptado`` o ``rechazado``)."""
ruta = os.path.join(CARPETA_PLANTILLAS, f"plantilla-{nombre}.html")
with open(ruta, encoding="utf-8") as f:
return f.read()
def renderizar_plantilla(nombre: str, contexto: dict) -> str:
"""Carga la plantilla ``nombre`` y la renderiza con ``contexto``."""
return renderizar(cargar_plantilla(nombre), contexto)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "descarga_factura_transporte"
version = "1.1.0"
version = "1.2.0"
description = "Descarga y procesamiento de facturas de transporte desde Office365"
requires-python = ">=3.11"
dependencies = [
+300
View File
@@ -0,0 +1,300 @@
"""
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_fuera_de_linea(email: dict, registro: dict, factura: dict) -> dict:
"""Aviso de que SAP no está disponible: el comprobante quedó en cola y se procesará solo."""
comprobante = _comprobante(factura)
numero = comprobante["comprobante_numero"]
return {
**_comun(email, registro), **comprobante,
"asunto": f"En cola · Factura {numero} · {registro['codigo_seguimiento']}",
"preheader": f"Su factura {numero} quedó en cola: nuestro sistema está fuera de línea. No necesita reenviarla.",
}
def contexto_rechazo_factura(email: dict, registro: dict, factura: dict, resultados_val: list[dict],
observaciones: list[obs.Observacion]) -> dict:
comprobante = _comprobante(factura)
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, estado_exito: str = "enviada") -> str:
"""
Responde al remitente con la plantilla ``plantilla`` ("aceptado", "rechazado" o "fuera-de-linea").
Args:
estado_exito: Estado que se anota si el envío sale bien (``"en_espera"`` para el aviso
de servicio fuera de línea).
Returns:
El estado anotado en ``comprobantes``: ``estado_exito``, ``"fallida"`` u ``"omitida"``.
"""
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 = estado_exito if enviado else "fallida"
log(f"{'✉️ ' if enviado else '❌'} Respuesta {plantilla} {estado} → {', '.join(para)} (modo {modo()})",
severity=logging.INFO if enviado else logging.ERROR)
_anotar(codigo, estado, None if enviado else "Microsoft Graph rechazó el envío (ver log)", ", ".join(para))
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)
+80 -12
View File
@@ -31,6 +31,15 @@ SAPB1_VERIFY_SSL = os.getenv("SAP_B1_VERIFY_SSL", "False").lower() == "true"
_sap_session: dict = {"headers": None, "b1session": None, "routeid": None}
class SapNoDisponible(ConnectionError):
"""
SAP no responde: timeout, sin conexión o error 5xx. Es una caída de la
infraestructura, no un rechazo de SAP (un HTTP 4xx sigue siendo un
``ConnectionError`` normal). Subclase de ``ConnectionError``, así que el
código que ya captura ``ConnectionError`` o ``Exception`` no cambia.
"""
# ============================================================================
# LOGIN
# ============================================================================
@@ -41,16 +50,18 @@ def login() -> dict:
"Password": SAPB1_PASSWORD,
}
logger.info("🔐 Iniciando sesión en SAP B1 Service Layer...")
resp = requests.post(
f"{SAPB1_URL}/Login",
json=payload,
timeout=SAPB1_TIMEOUT,
verify=SAPB1_VERIFY_SSL,
)
if resp.status_code != 200:
raise ConnectionError(
f"Login fallido: HTTP {resp.status_code} - {resp.text}"
try:
resp = requests.post(
f"{SAPB1_URL}/Login",
json=payload,
timeout=SAPB1_TIMEOUT,
verify=SAPB1_VERIFY_SSL,
)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
raise SapNoDisponible(f"No se pudo conectar con SAP B1 para iniciar sesión: {exc}") from exc
if resp.status_code != 200:
clase = SapNoDisponible if resp.status_code >= 500 else ConnectionError
raise clase(f"Login fallido: HTTP {resp.status_code} - {resp.text}")
data = resp.json()
b1session = data["SessionId"]
@@ -149,9 +160,9 @@ def forward_to_sapb1(
verify=SAPB1_VERIFY_SSL,
)
except requests.exceptions.Timeout:
raise ConnectionError(f"Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}")
raise SapNoDisponible(f"Timeout ({SAPB1_TIMEOUT}s) en {endpoint_path}")
except requests.exceptions.ConnectionError as exc:
raise ConnectionError(f"No se pudo conectar con SAP B1: {exc}")
raise SapNoDisponible(f"No se pudo conectar con SAP B1: {exc}")
except requests.RequestException as exc:
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
@@ -180,7 +191,8 @@ def forward_to_sapb1(
)
except (json.JSONDecodeError, AttributeError):
sap_message = resp.text
raise ConnectionError(
clase = SapNoDisponible if resp.status_code >= 500 else ConnectionError
raise clase(
f"Error en SAP {method} {endpoint_path}: "
f"HTTP {resp.status_code} - {sap_message}"
)
@@ -198,6 +210,50 @@ def create_draft(payload: dict) -> dict:
return result
# ============================================================================
# ¿LA FACTURA YA EXISTE EN SAP?
# ============================================================================
def _literal_odata(valor: Any) -> str:
"""Valor de texto para un $filter de OData: comillas simples duplicadas."""
return str(valor).replace("'", "''")
def buscar_factura_existente(ruc_emisor: str, tipo: str, serie: str, numero: str) -> dict | None:
"""
Busca una factura de proveedor en SAP B1 por proveedor (``PL`` + RUC), tipo, serie
y número (``U_BPP_MDTD``/``U_BPP_MDSD``/``U_BPP_MDCD``, los mismos campos que llena
el draft). Mira dos lugares: las facturas de compra **no anuladas** y los **drafts**
de factura de compra (una factura cuyo draft ya se creó, aunque nadie lo haya
contabilizado todavía).
Returns:
dict | None — ``{"entidad": "PurchaseInvoices" | "Drafts", "DocEntry": int, "DocNum": int}``
del primer documento encontrado, o None si la factura no existe.
Raises:
SapNoDisponible: Si SAP no responde (no se puede afirmar que no exista).
"""
base = (
f"CardCode eq 'PL{_literal_odata(ruc_emisor)}'"
f" and U_BPP_MDTD eq '{_literal_odata(tipo)}'"
f" and U_BPP_MDSD eq '{_literal_odata(serie)}'"
f" and U_BPP_MDCD eq '{_literal_odata(numero)}'"
)
for entidad, condicion in (
("PurchaseInvoices", " and Cancelled eq 'tNO'"),
("Drafts", " and DocObjectCode eq 'oPurchaseInvoices'"),
):
resp = forward_to_sapb1(
"GET", entidad,
params={"$filter": base + condicion, "$select": "DocEntry,DocNum", "$top": "1"},
)
encontrados = resp.json().get("value", [])
if encontrados:
logger.info(f" ├── 🔎 {serie}-{numero} ya existe en SAP ({entidad} {encontrados[0].get('DocEntry')})")
return {"entidad": entidad, "DocEntry": encontrados[0].get("DocEntry"), "DocNum": encontrados[0].get("DocNum")}
return None
# ============================================================================
# OBTENER FACTURA DE COMPRA (GET /PurchaseInvoices(DocEntry))
# ============================================================================
@@ -245,6 +301,10 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
Returns:
list[dict] — Documentos completos, o [] si no se encontró nada en
ninguna fuente.
Raises:
SapNoDisponible: Si Service Layer no responde y HANA no encontró nada
(no se puede afirmar que la guía no exista).
"""
codigo = f"{tipo}{serie}-{numero}"
@@ -275,6 +335,8 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
logger.info(f" │ └─ Orders({num_ov}) obtenido (Orden de Venta de StockTransfers({doc_entry}))")
docs.append(doc)
except SapNoDisponible:
raise
except Exception as e:
logger.warning(f" │ ⚠️ Error obteniendo {entidad}({doc_entry}): {e}")
@@ -284,6 +346,7 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
"""Fase 1: intenta SL, fallback a HANA. Retorna [{DocEntry, DocNum, CardCode, CardName, Entidad}, ...]."""
codigo = f"{tipo}{serie}-{numero}"
service_layer_caido = None
# ── Intentar Service Layer ───────────────────────────────────────────
try:
@@ -311,6 +374,8 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
logger.info(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...")
except Exception as e:
if isinstance(e, SapNoDisponible):
service_layer_caido = e
logger.warning(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...")
# ── Fallback a HANA ──────────────────────────────────────────────────
@@ -327,6 +392,9 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
except Exception as e:
logger.warning(f" │ ⚠️ Guía {codigo}: HANA falló ({e})")
if service_layer_caido is not None:
# SL no respondió y HANA no aportó nada: no es "la guía no existe", es una caída
raise SapNoDisponible(f"Service Layer no disponible y HANA sin resultados para la guía {codigo}: {service_layer_caido}")
return []
# ============================================================================
+44 -5
View File
@@ -18,14 +18,15 @@ El esquema se crea en ``database_utilities.init_db``.
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime
from datetime import datetime, timedelta
import database_utilities
PREFIJO_CODIGO = "ABR"
ESTADOS = ("pendiente", "aceptado", "rechazado", "error")
ESTADOS_NOTIFICACION = ("pendiente", "enviada", "fallida", "omitida")
# en_espera: se avisó al proveedor que el servicio está fuera de línea y el comprobante se reintentará
ESTADOS_NOTIFICACION = ("pendiente", "enviada", "fallida", "omitida", "en_espera")
# ============================================================================
@@ -159,7 +160,8 @@ def actualizar_resultado(codigo_seguimiento: str, estado: str, observaciones: li
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None) -> None:
def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | None = None,
destinatario: str | None = None) -> None:
"""
Guarda el estado de la notificación enviada al proveedor.
@@ -167,6 +169,7 @@ def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | No
codigo_seguimiento: Código del registro.
estado: Uno de ``ESTADOS_NOTIFICACION``.
error: Motivo, si la notificación falló o se omitió.
destinatario: Direcciones a las que se envió realmente (en modo prueba no son las del remitente).
Raises:
ValueError: Si ``estado`` no es válido.
@@ -179,9 +182,45 @@ def registrar_notificacion(codigo_seguimiento: str, estado: str, error: str | No
with _conexion() as conn:
cursor = conn.execute(
"""UPDATE comprobantes
SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?, actualizado_en = ?
SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?,
notificacion_destinatario = ?, actualizado_en = ?
WHERE codigo_seguimiento = ?""",
(estado, error, ahora, ahora, codigo_seguimiento),
(estado, error, ahora, destinatario, ahora, codigo_seguimiento),
)
if cursor.rowcount == 0:
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
def registrar_intento(codigo_seguimiento: str) -> int:
"""
Suma un intento fallido de procesar el comprobante (SAP fuera de línea) y devuelve el total.
Raises:
LookupError: Si no existe el código.
"""
with _conexion() as conn:
cursor = conn.execute(
"UPDATE comprobantes SET intentos = intentos + 1, actualizado_en = ? WHERE codigo_seguimiento = ?",
(_ahora(), codigo_seguimiento),
)
if cursor.rowcount == 0:
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
return conn.execute(
"SELECT intentos FROM comprobantes WHERE codigo_seguimiento = ?", (codigo_seguimiento,)
).fetchone()[0]
def contar_respuestas_correo(remitente: str, horas: int = 24) -> int:
"""
Cuenta las respuestas ya enviadas en las últimas ``horas`` a un remitente por
correos **sin comprobante** (sin serie ni número). Sirve de freno contra bucles
y contra remitentes que insisten con correos inválidos.
"""
desde = (datetime.now() - timedelta(hours=horas)).strftime("%Y-%m-%d %H:%M:%S")
with _conexion() as conn:
return conn.execute(
"""SELECT COUNT(*) FROM comprobantes c JOIN emails e ON e.id = c.email_id
WHERE c.serie IS NULL AND c.notificacion_estado = 'enviada'
AND c.notificacion_fecha >= ? AND lower(e.sender) = lower(?)""",
(desde, remitente),
).fetchone()[0]
+11
View File
@@ -23,3 +23,14 @@ def base_de_datos_temporal(tmp_path_factory, monkeypatch):
"""
monkeypatch.setattr(database_utilities, "DB_NAME", str(tmp_path_factory.mktemp("bd") / "emails_prueba.db"))
database_utilities.init_db()
@pytest.fixture(autouse=True)
def entorno_de_notificacion_aislado(monkeypatch):
"""
Los módulos cargan el ``.env`` real al importarse. Las pruebas no deben depender de él:
sin esto, un ``NOTIFICACION_MODO=real`` del .env haría que se probara (y enviara) en modo real.
"""
for variable in ("NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "NOTIFICACION_CONTACTO", "LOGO_URL"):
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv("EMAIL", "buzon@aberio.com")
+31 -1
View File
@@ -5,7 +5,8 @@ import pytest
import config_validator as cv
OBLIGATORIAS = [v for grupo in cv.OBLIGATORIAS.values() for v in grupo]
OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", *cv.VERIFICACION_SSL]
OPCIONALES = ["RUTA_RED_FACTURACION", "RUC_EMPRESA", "LOG_LEVEL", "NOTIFICACION_MODO", "NOTIFICACION_EQUIPO", "LOGO_URL",
*cv.VERIFICACION_SSL]
@pytest.fixture
@@ -74,3 +75,32 @@ def test_verificar_termina_con_codigo_1_si_hay_errores(env_completo, monkeypatch
def test_verificar_no_termina_si_todo_esta_bien(env_completo):
cv.verificar_configuracion() # no debe lanzar SystemExit
def test_el_modo_de_notificacion_se_anuncia_al_arrancar(env_completo, monkeypatch):
_, advertencias = cv.validar_configuracion()
assert any("PRUEBA" in a for a in advertencias) and not any("REAL" in a for a in advertencias)
monkeypatch.setenv("NOTIFICACION_MODO", "Real")
_, advertencias = cv.validar_configuracion()
assert any("REAL" in a and "quien envía el correo" in a for a in advertencias)
assert not any("PRUEBA" in a for a in advertencias)
def test_modo_de_notificacion_invalido_cae_en_prueba(env_completo, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "produccion")
errores, advertencias = cv.validar_configuracion()
assert errores == []
assert any("produccion" in a and "se usará prueba" in a for a in advertencias)
def test_equipo_y_logo_sin_definir_solo_advierten(env_completo, monkeypatch):
errores, advertencias = cv.validar_configuracion()
assert errores == []
assert any("NOTIFICACION_EQUIPO" in a for a in advertencias) and any("LOGO_URL" in a for a in advertencias)
monkeypatch.setenv("NOTIFICACION_EQUIPO", "a@x.com")
monkeypatch.setenv("LOGO_URL", "https://x.com/logo.png")
_, advertencias = cv.validar_configuracion()
assert not any("NOTIFICACION_EQUIPO" in a or "LOGO_URL" in a for a in advertencias)
+57
View File
@@ -92,6 +92,21 @@ def test_copiar_a_red_deshabilitada_si_no_hay_ruta(tmp_path, monkeypatch):
assert "ruta_red_xml" not in data and "ruta_red_pdf" not in data
def test_copiar_a_red_con_una_ruta_que_no_es_absoluta_no_crea_carpetas_ni_apunta_el_draft_a_la_nada(tmp_path, monkeypatch, caplog):
"""Fuera de Windows una ruta UNC no es absoluta: antes se creaba como una carpeta local con ese nombre."""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("RUTA_RED_FACTURACION", "red_relativa")
monkeypatch.setenv("RUC_EMPRESA", "20999")
data = _comprobante(tmp_path)
with caplog.at_level(logging.WARNING, logger="pipeline"):
documentos.copiar_a_red(data)
assert not (tmp_path / "red_relativa").exists() # no se creó nada
assert "ruta_red_xml" not in data and "ruta_red_pdf" not in data # el draft no referencia archivos inexistentes
assert any("no es una ruta absoluta" in r.getMessage() for r in caplog.records)
def test_copiar_a_red_fecha_invalida_no_falla(tmp_path, monkeypatch):
red = tmp_path / "red"
monkeypatch.setenv("RUTA_RED_FACTURACION", str(red))
@@ -121,3 +136,45 @@ def test_filtrar_sin_pdf_ni_xml_borra_todo(tmp_path):
assert adjuntos.filtrar_y_limpiar(email, str(tmp_path)) is False
assert os.listdir(tmp_path) == []
# ============================================================================
# Rutas: no dependen del directorio de trabajo
# ============================================================================
def test_la_base_de_datos_va_junto_al_codigo_aunque_se_lance_desde_otra_carpeta(tmp_path, monkeypatch):
import importlib.util
import database_utilities
monkeypatch.chdir(tmp_path) # un servicio puede arrancar desde cualquier carpeta
spec = importlib.util.spec_from_file_location("database_utilities_nueva", database_utilities.__file__)
nueva = importlib.util.module_from_spec(spec)
spec.loader.exec_module(nueva) # DB_NAME se calcula al importar
carpeta_del_codigo = os.path.dirname(os.path.abspath(database_utilities.__file__))
assert nueva.DB_NAME == os.path.join(carpeta_del_codigo, "emails.db")
def test_ruta_carpeta_no_depende_del_directorio_actual(tmp_path, monkeypatch):
import pipeline_utils
monkeypatch.chdir(tmp_path)
esperado = os.path.join(os.path.dirname(os.path.abspath(pipeline_utils.__file__)), "resultado")
assert pipeline_utils.ruta_carpeta("resultado") == esperado
def test_los_resultados_relativos_se_guardan_con_ruta_carpeta_no_en_el_cwd(tmp_path, monkeypatch):
import persistencia
proyecto, otro = tmp_path / "proyecto", tmp_path / "otro"
proyecto.mkdir(), otro.mkdir()
monkeypatch.chdir(otro)
monkeypatch.setattr(persistencia, "ruta_carpeta", lambda r: os.path.join(proyecto, r))
persistencia.guardar_draft_json({"a": 1}, "F002", "1", "resultado")
assert any(n.startswith("draft_sapb1_F002-1") for n in os.listdir(proyecto / "resultado"))
assert os.listdir(otro) == [] # nada en el directorio desde el que se lanzó
def test_una_carpeta_de_resultados_absoluta_se_respeta(tmp_path):
import persistencia
persistencia.guardar_draft_json({"a": 1}, "F002", "2", str(tmp_path / "salida"))
assert any(n.startswith("draft_sapb1_F002-2") for n in os.listdir(tmp_path / "salida"))
+45
View File
@@ -233,3 +233,48 @@ def test_valor_referencial_consulta_una_vez_por_guia(monkeypatch):
assert llamadas == [("T001", "1"), ("T001", "2")]
assert [r["valor_sap"] for r in resultado] == [7.5, 7.5]
# ============================================================================
# Caídas de SAP y errores de consulta
# ============================================================================
def test_validate_prices_deja_pasar_una_caida_de_sap(sap, monkeypatch):
def cae(**k):
raise draft_sap.sapb1_manager.SapNoDisponible("sin conexión")
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", cae)
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
with pytest.raises(draft_sap.sapb1_manager.SapNoDisponible): # no se disfraza de "error de la guía"
draft_sap.validate_prices(factura)
def test_validate_prices_marca_el_error_de_consulta_de_la_guia(sap, monkeypatch):
def falla(**k):
raise ValueError("respuesta rara de SAP")
monkeypatch.setattr(draft_sap.sapb1_manager, "get_delivery_notes", falla)
factura = {"lineas": [{"cantidad": 1, "total_linea": 100.0, "guias_remision": [_guia()]}]}
_, resultados = draft_sap.validate_prices(factura)
assert resultados[0]["guias_lt"][0]["error"] == "respuesta rara de SAP"
assert draft_sap.hay_error_de_consulta(resultados) == "respuesta rara de SAP"
def test_hay_error_de_consulta_sin_errores():
assert draft_sap.hay_error_de_consulta([]) is None
assert draft_sap.hay_error_de_consulta([{"guias_lt": [{"guia": {}, "line_total": 0.0}]}, {"guias_lt": []}]) is None
def test_hana_disponible(monkeypatch):
cerrada = []
class Conexion:
def close(self):
cerrada.append(True)
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: Conexion())
assert draft_sap.hana_disponible() is True and cerrada == [True] # abre y cierra: no deja conexiones colgando
monkeypatch.setattr(draft_sap.hana_manager, "get_connection", lambda: None)
assert draft_sap.hana_disponible() is False
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
import main
import pipeline_utils
def _intervalo(hora, minuto):
return main.obtener_intervalo(datetime(2026, 10, 22, hora, minuto))
def test_cada_minuto_del_dia_pertenece_a_un_bloque_y_solo_uno():
"""Sin huecos (el demonio dormiría hasta las 06:30) ni solapes."""
for minuto_del_dia in range(24 * 60):
bloques = [b for b in main.HORARIOS if b[0] * 60 + b[1] <= minuto_del_dia < b[2] * 60 + b[3]]
assert len(bloques) == 1, f"{minuto_del_dia // 60:02d}:{minuto_del_dia % 60:02d} -> {len(bloques)} bloques"
assert _intervalo(minuto_del_dia // 60, minuto_del_dia % 60) == (bloques[0][4], bloques[0][5])
def test_los_bloques_estan_ordenados_y_se_encadenan():
fines = [b[2] * 60 + b[3] for b in main.HORARIOS]
inicios = [b[0] * 60 + b[1] for b in main.HORARIOS]
assert inicios[0] == 0 and fines[-1] == 24 * 60
assert inicios[1:] == fines[:-1] # cada bloque empieza donde termina el anterior
def test_los_huecos_de_antes_ahora_tienen_horario():
assert _intervalo(21, 29) == (3600, "full") and _intervalo(21, 30) == (3600, "full")
assert _intervalo(23, 59) == (3600, "full")
assert _intervalo(5, 0) == (1800, "full") and _intervalo(6, 29) == (1800, "full")
assert _intervalo(6, 30) == (300, "rapido")
def test_la_version_del_proyecto_sale_de_pyproject():
version = pipeline_utils.version_del_proyecto()
assert version != "desconocida" and version.count(".") == 2
+105
View File
@@ -0,0 +1,105 @@
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.factura_ya_registrada("F002-1"), obs.sin_pdf_xml(0), obs.falta_xml(1), obs.sin_factura([]),
]
for o in muestras:
assert o.titulo and o.como_corregir and o.codigo
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"]}
def test_factura_ya_registrada():
o = obs.factura_ya_registrada("F002-486")
assert o.codigo == "factura_ya_registrada" and o.filas == (("Comprobante", "F002-486"),)
assert "No necesita enviarla otra vez" in o.como_corregir
+97
View File
@@ -0,0 +1,97 @@
import outlook_utilities as o
# ============================================================================
# es_mensaje_automatico
# ============================================================================
def _h(**kv):
return [{"name": k.replace("_", "-"), "value": v} for k, v in kv.items()]
def test_mensajes_normales_no_son_automaticos():
assert o.es_mensaje_automatico(None) is False
assert o.es_mensaje_automatico([]) is False
assert o.es_mensaje_automatico(_h(Auto_Submitted="no", Content_Type="text/html", Precedence="normal")) is False
def test_mensajes_automaticos():
assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-replied")) is True
assert o.es_mensaje_automatico(_h(Auto_Submitted="auto-generated")) is True
assert o.es_mensaje_automatico(_h(X_Auto_Response_Suppress="All")) is True
assert o.es_mensaje_automatico(_h(X_Autoreply="yes")) is True
assert o.es_mensaje_automatico(_h(Precedence="bulk")) is True
assert o.es_mensaje_automatico(_h(Precedence="auto_reply")) is True
assert o.es_mensaje_automatico(_h(Content_Type="multipart/report; report-type=delivery-status")) is True
def test_los_nombres_de_encabezado_no_distinguen_mayusculas():
assert o.es_mensaje_automatico([{"name": "AUTO-SUBMITTED", "value": "Auto-Replied"}]) is True
# ============================================================================
# send_email (payload hacia Graph)
# ============================================================================
class _Respuesta:
def __init__(self, status=202, text=""):
self.status_code, self.text = status, text
def test_send_email_incluye_destinatarios_copia_y_copia_oculta(monkeypatch):
enviado = {}
monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(url=url, **k) or _Respuesta())
ok = o.send_email("tok", "buzon@x.com", ["a@x.com"], "Asunto", "<p>hola</p>", None,
cc_recipients=["c@x.com"], bcc_recipients=["oculto@x.com"])
mensaje = enviado["json"]["message"]
assert ok is True and enviado["url"].endswith("/users/buzon@x.com/sendMail")
assert mensaje["toRecipients"] == [{"emailAddress": {"address": "a@x.com"}}]
assert mensaje["ccRecipients"] == [{"emailAddress": {"address": "c@x.com"}}]
assert mensaje["bccRecipients"] == [{"emailAddress": {"address": "oculto@x.com"}}]
assert mensaje["attachments"] == [] and mensaje["body"]["contentType"] == "HTML"
def test_send_email_sin_copia_oculta_envia_la_lista_vacia(monkeypatch):
enviado = {}
monkeypatch.setattr(o.requests, "post", lambda url, **k: enviado.update(**k) or _Respuesta())
o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>")
assert enviado["json"]["message"]["bccRecipients"] == []
def test_send_email_devuelve_false_si_graph_rechaza_o_falla(monkeypatch):
monkeypatch.setattr(o.requests, "post", lambda *a, **k: _Respuesta(400, "mal"))
assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>") is False
def explota(*a, **k):
raise ConnectionError("sin red")
monkeypatch.setattr(o.requests, "post", explota)
assert o.send_email("tok", "b@x.com", ["a@x.com"], "A", "<p/>") is False
# ============================================================================
# get_emails
# ============================================================================
def test_get_emails_pide_los_encabezados_y_marca_los_automaticos(monkeypatch):
pedido = {}
class Resp:
status_code = 200
def json(self):
return {"value": [
{"id": "1", "receivedDateTime": "2026-10-14T15:35:00Z", "hasAttachments": True, "subject": "Factura",
"from": {"emailAddress": {"address": "a@x.com"}}, "internetMessageHeaders": []},
{"id": "2", "receivedDateTime": "2026-10-14T16:00:00Z", "hasAttachments": False, "subject": "Auto",
"from": {"emailAddress": {"address": "b@x.com"}},
"internetMessageHeaders": [{"name": "Auto-Submitted", "value": "auto-replied"}]},
{"id": "3", "receivedDateTime": "2026-10-14T16:00:00Z", "subject": "Sin encabezados", "from": {}},
]}
monkeypatch.setattr(o.requests, "get", lambda url, **k: pedido.update(k) or Resp())
correos = o.get_emails("buzon@x.com", "tok", "2026-10-14", "2026-10-14")
assert "internetMessageHeaders" in pedido["params"]["$select"]
assert [c["es_automatico"] for c in correos] == [False, True, False]
assert correos[0]["fecha"] == "2026-10-14 10:35:00" # UTC-5
assert correos[2]["sender"] == "Desconocido"
+441 -80
View File
@@ -1,7 +1,11 @@
"""
Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y la
BD simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de
624.38 con la guía T001-128912).
Flujo completo de ``pipeline._procesar_un_correo`` con SAP, HANA, Outlook y Graph
simulados. Usa el correo de prueba 001 (factura F002-2450, una línea de 624.38
con la guía T001-128912).
Cada factura genera dos correos: el **técnico**, solo para el equipo (asunto
"Draft SAP B1 - …"), y la **respuesta al proveedor** con la plantilla de aceptado o
rechazado (asunto "Aceptado · …" / "Rechazado · …").
"""
import glob
import logging
@@ -21,39 +25,64 @@ from conftest import FIXTURES
CASO = "001_correo"
TOTAL_LINEA = 624.38
SENDER = "proveedor@empresa.com"
EQUIPO = ["equipo1@aberio.com", "equipo2@aberio.com"]
class Entorno:
"""Registra lo que el pipeline le pide a los servicios externos."""
"""Registra lo que el pipeline le pide a los servicios externos y permite alterar sus respuestas."""
def __init__(self):
self.line_total_sap = TOTAL_LINEA
self.guia_existe = True # False: SAP no devuelve ningún documento para la guía
self.sap_lanza = None # excepción que lanza get_delivery_notes
self.error_al_crear = None
self.hana_disponible = True
self.existente = None # dict: la factura ya existe en SAP
self.sap_caido = False # SAP no responde (en la búsqueda de la factura)
self.sap_caido_en_validacion = False
self.archivos = None # None: usa los del caso 001; dict {nombre: bytes}: esos archivos
self.drafts = []
self.correos = []
self.descargas = 0
@property
def tecnicos(self):
return [c for c in self.correos if "Draft SAP B1" in c["asunto"]]
@property
def respuestas(self):
return [c for c in self.correos if " · " in c["asunto"]
and any(p in c["asunto"] for p in ("Aceptado", "Rechazado", "En cola"))]
@pytest.fixture
def entorno(tmp_path, monkeypatch):
e = Entorno()
monkeypatch.setenv("RUTA_RED_FACTURACION", "")
monkeypatch.setenv("NOTIFICACION_EQUIPO", ",".join(EQUIPO))
monkeypatch.setattr(database_utilities, "get_existing_ids", lambda ids: [])
monkeypatch.setattr(database_utilities, "save_emails", lambda emails: None)
def descargar(addr, id_, token, carpeta):
e.descargas += 1
os.makedirs(carpeta, exist_ok=True) # como el download_attachments real
os.makedirs(carpeta, exist_ok=True) # como el download_attachments real
nombres = []
for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")):
if ruta.lower().endswith((".pdf", ".xml")):
shutil.copy(ruta, carpeta)
nombres.append(os.path.basename(ruta))
if e.archivos is None:
for ruta in glob.glob(os.path.join(FIXTURES, CASO, "*")):
if ruta.lower().endswith((".pdf", ".xml")):
shutil.copy(ruta, carpeta)
nombres.append(os.path.basename(ruta))
else:
for nombre, contenido in e.archivos.items():
with open(os.path.join(carpeta, nombre), "wb") as f:
f.write(contenido)
nombres.append(nombre)
return nombres
def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None):
e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients, "adjuntos": adjuntos})
def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None):
e.correos.append({"para": para, "asunto": asunto, "html": html, "cc": cc_recipients,
"bcc": bcc_recipients, "adjuntos": adjuntos})
return True
def crear_draft(draft):
@@ -63,73 +92,261 @@ def entorno(tmp_path, monkeypatch):
return {"DocEntry": 777}
def delivery_notes(**k):
if e.sap_lanza:
raise e.sap_lanza
if not e.guia_existe:
return []
return [{"Series": "1", "DocNum": "9", "U_IDC_DESTINO": "Z1", "DocumentLines": [
{"ItemCode": "FL10007", "LineTotal": e.line_total_sap, "PriceAfterVAT": 1.3396,
"Quantity": 466, "WarehouseCode": "A-CENTRA", "Weight1": 1}]}]
class ConexionHana:
def close(self):
pass
monkeypatch.setattr(outlook_utilities, "download_attachments", descargar)
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
monkeypatch.setattr(sapb1_manager, "get_zonas", lambda: [{"Code": "Z1", "Name": "ZONA1"}])
def buscar_existente(ruc, tipo, serie, numero):
if e.sap_caido:
raise sapb1_manager.SapNoDisponible("No se pudo conectar con SAP B1")
return e.existente
def zonas():
if e.sap_caido_en_validacion:
raise sapb1_manager.SapNoDisponible("Timeout en Drafts")
return [{"Code": "Z1", "Name": "ZONA1"}]
monkeypatch.setattr(sapb1_manager, "buscar_factura_existente", buscar_existente)
monkeypatch.setattr(sapb1_manager, "get_zonas", zonas)
monkeypatch.setattr(sapb1_manager, "get_delivery_notes", delivery_notes)
monkeypatch.setattr(sapb1_manager, "obtener_condicion_pago", lambda c: {"NumberOfAdditionalDays": 30})
monkeypatch.setattr(sapb1_manager, "create_draft", crear_draft)
monkeypatch.setattr(hana_manager, "obtener_valor_referencial", lambda s, n: 0.0)
monkeypatch.setattr(hana_manager, "get_connection", lambda: ConexionHana() if e.hana_disponible else None)
e.descarga = tmp_path / "descarga"
e.salida = tmp_path / "salida"
return e
def _procesar(e, sender="proveedor@empresa.com"):
email = {"id": "abc", "fecha": "2026-10-14 10:00:00", "subject": "Factura", "sender": sender}
pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(e.descarga), str(e.salida))
def _procesar(e, sender=SENDER, subject="Factura", id_="abc", es_automatico=False, carpeta=None):
email = {"id": id_, "fecha": "2026-10-14 10:35:00", "subject": subject, "sender": sender,
"has_attach": True, "es_automatico": es_automatico}
pipeline._procesar_un_correo(email, "token", "buzon@aberio.com", str(carpeta or e.descarga), str(e.salida))
return email
def test_precios_coinciden_crea_draft_y_notifica(entorno):
def _registros():
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.row_factory = sqlite3.Row
try:
return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")]
finally:
conn.close()
# ============================================================================
# Aceptado
# ============================================================================
def test_aceptado_crea_draft_y_responde_con_la_plantilla(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno)
assert len(entorno.drafts) == 1
draft = entorno.drafts[0]
assert draft["CardCode"] == "PL10060028686"
assert draft["NumAtCard"] == "01F002-2450"
assert draft["DocumentLines"][0]["U_IDC_GRFLETE"] == "09T001-128912"
assert draft["CardCode"] == "PL10060028686" and draft["NumAtCard"] == "01F002-2450"
assert len(entorno.correos) == 1
correo = entorno.correos[0]
assert correo["para"] == ["proveedor@empresa.com"]
assert correo["asunto"] == "Draft SAP B1 - F002-2450"
assert "creado exitosamente" in correo["html"] and "777" in correo["html"]
assert correo["cc"] # siempre se copia al equipo
assert any(a.endswith(".xml") for a in correo["adjuntos"])
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER] # al que envió el correo
assert respuesta["bcc"] == EQUIPO # el equipo en copia oculta
assert respuesta["cc"] is None and respuesta["adjuntos"] is None
assert respuesta["asunto"].startswith("Aceptado · Factura F002-2450 · ABR-")
assert "Registramos su factura" in respuesta["html"]
assert "09T001-128912" not in respuesta["html"] and "T001-128912" in respuesta["html"]
assert "S/ 624.38" in respuesta["html"]
assert "Pago estimado" in respuesta["html"] # hay condición de pago de 30 días
def test_precios_no_coinciden_no_crea_draft_pero_notifica(entorno):
def test_el_correo_tecnico_va_solo_al_equipo_nunca_al_remitente(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno)
(tecnico,) = entorno.tecnicos
assert tecnico["para"] == EQUIPO
assert SENDER not in tecnico["para"] and not tecnico["cc"] and not tecnico["bcc"]
assert any(a.endswith(".xml") for a in tecnico["adjuntos"]) # lleva los adjuntos internos
assert f"<b>Remitente:</b> {SENDER}" in tecnico["html"] and "ABR-" in tecnico["html"]
assert SENDER not in [d for c in entorno.respuestas for d in c["para"] if c["asunto"].startswith("[")]
def test_modo_prueba_es_el_predeterminado_y_no_escribe_al_remitente(entorno):
_procesar(entorno) # NOTIFICACION_MODO sin definir
(respuesta,) = entorno.respuestas
assert respuesta["para"] == EQUIPO
assert respuesta["bcc"] is None
assert respuesta["asunto"].startswith(f"[PRUEBA → {SENDER}] Aceptado · Factura F002-2450")
assert all(SENDER not in c["para"] for c in entorno.correos) # a nadie fuera del equipo
# ============================================================================
# Rechazado
# ============================================================================
def test_precios_distintos_rechaza_y_muestra_ambos_importes(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.line_total_sap = TOTAL_LINEA + 50
_procesar(entorno)
assert entorno.drafts == []
assert len(entorno.correos) == 1
assert "Draft NO creado" in entorno.correos[0]["html"]
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER]
assert respuesta["asunto"].startswith("Rechazado · Factura F002-2450")
html = respuesta["html"]
assert "No pudimos registrar su factura" in html and "1 observación" in html
assert "El importe no coincide con nuestro registro" in html
assert "S/ 624.38" in html and "S/ 674.38" in html # importe de la factura y esperado
assert "No coincide" in html
def test_error_al_crear_draft_se_notifica(entorno):
entorno.error_al_crear = RuntimeError("Service Layer caído")
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_servicio_fuera_de_linea_y_no_rechazo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.guia_existe = False
entorno.hana_disponible = False
_procesar(entorno)
assert entorno.drafts == []
assert len(entorno.correos) == 1
assert "Error al crear Draft" in entorno.correos[0]["html"]
assert "Service Layer caído" in entorno.correos[0]["html"]
(aviso,) = entorno.respuestas # no se rechaza: se avisa que está en cola
assert aviso["asunto"].startswith("En cola · Factura F002-2450") and "No encontramos la guía" not in aviso["html"]
(tecnico,) = entorno.tecnicos
assert "HANA" in tecnico["html"] and tecnico["asunto"].startswith("SAP fuera de línea")
(registro,) = _registros()
assert registro["estado"] == "error" and registro["notificacion_estado"] == "en_espera"
def test_remitente_desconocido_no_envia_correo(entorno):
def test_error_consultando_sap_es_error_interno_y_no_rechazo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_lanza = RuntimeError("sin conexión")
_procesar(entorno)
assert entorno.respuestas == [] and entorno.drafts == []
assert len(entorno.tecnicos) == 1
assert _registros()[0]["estado"] == "error"
def test_error_al_crear_draft_avisa_solo_al_equipo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.error_al_crear = RuntimeError("Service Layer caído")
_procesar(entorno)
assert entorno.respuestas == []
(tecnico,) = entorno.tecnicos
assert "Error al crear Draft" in tecnico["html"] and "Service Layer caído" in tecnico["html"]
(registro,) = _registros()
assert registro["estado"] == "error" and registro["notificacion_estado"] == "omitida"
# ============================================================================
# Correos sin comprobante válido
# ============================================================================
def test_correo_con_adjuntos_que_no_son_pdf_ni_xml_se_rechaza(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {"foto.png": b"x", "nota.docx": b"y"}
_procesar(entorno)
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER]
assert respuesta["asunto"].startswith("Rechazado · Comprobante no recibido · ABR-")
html = respuesta["html"]
assert "No recibimos el XML ni el PDF de su comprobante" in html
assert "Guías revisadas" not in html # sin tabla de guías vacía
assert "RUC —" not in html and "Factura electrónica" not in html # sin datos de comprobante inventados
assert html.count("RUC") == 1 # solo el del pie (RUC de A. Berio)
assert "{{" not in html
assert entorno.tecnicos == [] and entorno.drafts == []
(registro,) = _registros()
assert registro["serie"] is None and registro["estado"] == "rechazado"
assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER
def test_solo_pdf_sin_xml_pide_el_xml(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
pdf = glob.glob(os.path.join(FIXTURES, "002_correo", "*.pdf"))[0]
entorno.archivos = {os.path.basename(pdf): open(pdf, "rb").read()}
_procesar(entorno)
(respuesta,) = entorno.respuestas
assert "Falta el archivo XML de su comprobante" in respuesta["html"]
def test_correo_sin_adjuntos_no_se_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {}
_procesar(entorno)
assert entorno.correos == [] and _registros() == []
@pytest.mark.parametrize("sender, subject, automatico, motivo", [
("no-reply@empresa.com", "Factura", False, "automática"),
("buzon@aberio.com", "Factura", False, "propio buzón"),
("persona@local", "Factura", False, "no válida"),
(SENDER, "Fuera de oficina", True, "automático"),
(SENDER, "Re: Aceptado · Factura F002-1 · ABR-261019-00042", False, "respuesta a una notificación"),
(SENDER, "RE: gracias", False, "respuesta a una notificación"),
])
def test_correos_sin_comprobante_a_los_que_no_se_responde(entorno, monkeypatch, sender, subject, automatico, motivo):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {"foto.png": b"x"}
_procesar(entorno, sender=sender, subject=subject, es_automatico=automatico)
assert entorno.correos == []
(registro,) = _registros()
assert registro["notificacion_estado"] == "omitida" and motivo in registro["notificacion_error"]
def test_limite_de_respuestas_por_remitente(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.archivos = {"foto.png": b"x"}
for n in range(4):
_procesar(entorno, id_=f"correo-{n}", carpeta=entorno.descarga.parent / f"d{n}")
assert [r["notificacion_estado"] for r in _registros()] == ["enviada", "enviada", "enviada", "omitida"]
assert len(entorno.respuestas) == 3
# ============================================================================
# Remitente y registro
# ============================================================================
def test_remitente_desconocido_crea_el_draft_pero_no_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno, sender="Desconocido")
assert len(entorno.drafts) == 1 # el draft se crea igual
assert entorno.correos == []
assert len(entorno.drafts) == 1
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
assert _registros()[0]["notificacion_estado"] == "omitida"
def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
@@ -137,8 +354,7 @@ def test_correo_ya_procesado_se_salta(entorno, monkeypatch):
_procesar(entorno)
assert entorno.descargas == 0
assert entorno.drafts == [] and entorno.correos == []
assert entorno.descargas == 0 and entorno.drafts == [] and entorno.correos == []
def test_guarda_resultados_y_draft_en_disco(entorno):
@@ -149,69 +365,50 @@ def test_guarda_resultados_y_draft_en_disco(entorno):
assert any(n.startswith("draft_sapb1_F002-2450") for n in nombres)
# ============================================================================
# Seguimiento
# ============================================================================
def _registros():
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.row_factory = sqlite3.Row
try:
return [dict(f) for f in conn.execute("SELECT * FROM comprobantes ORDER BY id")]
finally:
conn.close()
def test_aceptado_deja_el_resultado_y_la_notificacion_registrados(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
def test_draft_creado_deja_el_comprobante_aceptado(entorno):
_procesar(entorno)
(registro,) = _registros()
assert registro["estado"] == "aceptado"
assert registro["doc_entry"] == 777
assert registro["codigo_seguimiento"].startswith("ABR-")
assert (registro["serie"], registro["numero"], registro["tipo"]) == ("F002", "2450", "01")
assert registro["ruc_emisor"] == "10060028686"
assert (registro["estado"], registro["doc_entry"], registro["tipo"]) == ("aceptado", 777, "01")
assert (registro["serie"], registro["numero"], registro["ruc_emisor"]) == ("F002", "2450", "10060028686")
assert registro["notificacion_estado"] == "enviada" and registro["notificacion_destinatario"] == SENDER
def test_precios_que_no_coinciden_dejan_el_comprobante_rechazado(entorno):
def test_modo_prueba_registra_a_quien_se_envio_de_verdad(entorno):
_procesar(entorno)
assert _registros()[0]["notificacion_destinatario"] == ", ".join(EQUIPO)
def test_rechazo_guarda_las_observaciones_completas(entorno):
entorno.line_total_sap = TOTAL_LINEA + 50
_procesar(entorno)
(registro,) = _registros()
assert registro["estado"] == "rechazado"
assert registro["doc_entry"] is None
assert seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"] == [{"codigo": "precios_no_coinciden"}]
def test_error_al_crear_draft_deja_el_comprobante_en_error(entorno):
entorno.error_al_crear = RuntimeError("Service Layer caído")
_procesar(entorno)
(registro,) = _registros()
assert registro["estado"] == "error"
obs = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
assert obs == [{"codigo": "error_sap", "detalle": "Service Layer caído"}]
(observacion,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
assert observacion["codigo"] == "precio_no_coincide"
assert {"etiqueta": "Importe esperado", "valor": "S/ 674.38"} in observacion["filas"]
def test_comprobante_reenviado_conserva_el_codigo_y_se_advierte(entorno, caplog):
_procesar(entorno)
(primero,) = _registros()
entorno.descarga = entorno.descarga.parent / "descarga2" # otra descarga, otro correo
with caplog.at_level(logging.WARNING, logger="pipeline"):
pipeline._procesar_un_correo(
{"id": "otro-correo", "fecha": "2026-10-19 11:00:00", "subject": "Reenvío", "sender": "p@e.com"},
"token", "buzon@aberio.com", str(entorno.descarga), str(entorno.salida),
)
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
(registro,) = _registros() # sigue habiendo una sola fila
(registro,) = _registros() # sigue habiendo una sola fila
assert registro["codigo_seguimiento"] == primero["codigo_seguimiento"]
assert registro["veces_recibido"] == 2
assert any("ya recibido antes" in r.getMessage() for r in caplog.records)
def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch):
def test_si_falla_el_seguimiento_igual_se_crea_el_draft_pero_no_se_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
def roto(*a, **k):
raise sqlite3.OperationalError("database is locked")
@@ -221,4 +418,168 @@ def test_si_falla_el_seguimiento_igual_se_crea_el_draft(entorno, monkeypatch):
_procesar(entorno)
assert len(entorno.drafts) == 1
assert len(entorno.correos) == 1
assert len(entorno.tecnicos) == 1 and entorno.respuestas == [] # sin código no se responde al proveedor
def test_un_fallo_inesperado_en_una_factura_no_tumba_el_ciclo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
monkeypatch.setattr(pipeline.draft_sap, "build_sap_draft", lambda *a, **k: 1 / 0)
_procesar(entorno) # no debe lanzar excepción
assert entorno.drafts == [] and entorno.respuestas == []
assert "ZeroDivisionError" in entorno.tecnicos[0]["html"]
assert _registros()[0]["estado"] == "error"
# ============================================================================
# La factura ya existe en SAP
# ============================================================================
def _ids_de_correos_guardados():
conn = sqlite3.connect(database_utilities.DB_NAME)
try:
return [f[0] for f in conn.execute("SELECT id FROM emails")]
finally:
conn.close()
def test_factura_que_ya_existe_en_sap_no_crea_otro_draft_y_se_rechaza(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.existente = {"entidad": "PurchaseInvoices", "DocEntry": 55, "DocNum": 9}
_procesar(entorno)
assert entorno.drafts == []
(respuesta,) = entorno.respuestas
assert respuesta["para"] == [SENDER] and respuesta["asunto"].startswith("Rechazado · Factura F002-2450")
assert "Esta factura ya está registrada en nuestro sistema" in respuesta["html"]
(tecnico,) = entorno.tecnicos
assert "ya existe en SAP" in tecnico["html"] and "PurchaseInvoices 55" in tecnico["html"]
assert not any(n.startswith("draft_sapb1") for n in os.listdir(entorno.salida)) # ni se armó el draft
assert _registros()[0]["estado"] == "rechazado"
def test_el_reenvio_de_una_factura_aceptada_no_pisa_el_registro_original(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno) # se acepta: draft 777
entorno.existente = {"entidad": "Drafts", "DocEntry": 777, "DocNum": 3}
_procesar(entorno, id_="otro-correo", carpeta=entorno.descarga.parent / "descarga2")
(registro,) = _registros()
assert (registro["estado"], registro["doc_entry"], registro["veces_recibido"]) == ("aceptado", 777, 2)
assert len(entorno.drafts) == 1 # no se creó un segundo draft
assert "ya está registrada" in entorno.respuestas[-1]["html"]
def test_reprocesar_el_mismo_correo_ya_resuelto_no_repite_nada(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
_procesar(entorno)
enviados = len(entorno.correos)
_procesar(entorno) # mismo correo otra vez
assert len(entorno.drafts) == 1 and len(entorno.correos) == enviados
# ============================================================================
# SAP fuera de línea
# ============================================================================
def test_sap_fuera_de_linea_avisa_al_proveedor_y_deja_el_correo_en_cola(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
_procesar(entorno)
assert entorno.drafts == []
(aviso,) = entorno.respuestas
assert aviso["para"] == [SENDER] and aviso["asunto"].startswith("En cola · Factura F002-2450 · ABR-")
assert "Recibimos su factura" in aviso["html"] and "temporalmente fuera de línea" in aviso["html"]
assert "No necesita reenviarla" in aviso["html"] and "{{" not in aviso["html"]
(tecnico,) = entorno.tecnicos
assert tecnico["para"] == EQUIPO and tecnico["asunto"].startswith("SAP fuera de línea - Draft SAP B1")
(registro,) = _registros()
assert (registro["estado"], registro["notificacion_estado"], registro["intentos"]) == ("error", "en_espera", 1)
(obs,) = seguimiento.obtener(registro["codigo_seguimiento"])["observaciones"]
assert obs["codigo"] == "servicio_no_disponible" and obs["intentos"] == 1
assert "abc" not in _ids_de_correos_guardados() # olvidado: el próximo ciclo lo vuelve a tomar
def test_los_reintentos_no_repiten_el_aviso_y_al_volver_sap_se_responde(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
_procesar(entorno)
_procesar(entorno) # el ciclo siguiente: SAP sigue caído
assert len(entorno.respuestas) == 1 and len(entorno.tecnicos) == 1 # un solo aviso, no uno por ciclo
assert _registros()[0]["intentos"] == 2 and "abc" not in _ids_de_correos_guardados()
entorno.sap_caido = False
_procesar(entorno) # SAP volvió
asuntos = [c["asunto"] for c in entorno.respuestas]
assert asuntos[0].startswith("En cola") and asuntos[1].startswith("Aceptado") and len(asuntos) == 2
(registro,) = _registros()
assert (registro["estado"], registro["doc_entry"], registro["notificacion_estado"]) == ("aceptado", 777, "enviada")
assert len(entorno.drafts) == 1
assert "abc" in _ids_de_correos_guardados() # ya resuelto: no se vuelve a tomar
def test_se_agotan_los_reintentos_y_se_avisa_al_equipo(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
monkeypatch.setattr(pipeline, "MAX_REINTENTOS", 2)
entorno.sap_caido = True
_procesar(entorno)
_procesar(entorno)
assert [c["asunto"].split(" - ")[0] for c in entorno.tecnicos] == ["SAP fuera de línea", "Reintentos agotados"]
assert len(entorno.respuestas) == 1 # el proveedor solo recibió el aviso inicial
assert "abc" in _ids_de_correos_guardados() # deja de reintentarse
registro = _registros()[0]
assert registro["notificacion_estado"] == "omitida" and "reintentos" in registro["notificacion_error"]
@pytest.mark.parametrize("como_falla", ["validacion", "crear_draft"])
def test_la_caida_en_cualquier_paso_es_fuera_de_linea(entorno, monkeypatch, como_falla):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
if como_falla == "validacion":
entorno.sap_caido_en_validacion = True
else:
entorno.error_al_crear = sapb1_manager.SapNoDisponible("HTTP 503 - Service Unavailable")
_procesar(entorno)
assert entorno.drafts == []
(aviso,) = entorno.respuestas
assert aviso["asunto"].startswith("En cola · Factura F002-2450")
assert "abc" not in _ids_de_correos_guardados()
def test_un_rechazo_normal_de_sap_no_es_una_caida(entorno, monkeypatch):
"""HTTP 400 al crear el draft: es un problema del dato o de SAP, no se reintenta ni se le dice nada al proveedor."""
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.error_al_crear = ConnectionError("Error en SAP POST Drafts: HTTP 400 - ItemCode inexistente")
_procesar(entorno)
assert entorno.respuestas == []
assert "HTTP 400" in entorno.tecnicos[0]["html"]
assert _registros()[0]["estado"] == "error" and "abc" in _ids_de_correos_guardados()
def test_sin_seguimiento_una_caida_no_se_reintenta(entorno, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
entorno.sap_caido = True
def roto(*a, **k):
raise sqlite3.OperationalError("database is locked")
monkeypatch.setattr(seguimiento, "registrar_comprobante", roto)
_procesar(entorno)
assert entorno.respuestas == [] and len(entorno.tecnicos) == 1
assert "abc" in _ids_de_correos_guardados() # sin la cuenta de intentos no se arriesga un bucle
+188
View File
@@ -0,0 +1,188 @@
import json
import os
import pytest
import plantillas
from plantillas import PlantillaError, renderizar
EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json")
def _ejemplos() -> dict:
with open(EJEMPLOS, encoding="utf-8") as f:
return json.load(f)
# ============================================================================
# Motor de plantillas
# ============================================================================
def test_variable_con_escape_de_html():
assert renderizar("{{x}}", {"x": 'A & B <i> "c" \'d\''}) == "A &amp; B &lt;i&gt; &quot;c&quot; &#x27;d&#x27;"
def test_variable_none_queda_vacia_y_numeros_se_convierten():
assert renderizar("[{{a}}][{{b}}]", {"a": None, "b": 12.5}) == "[][12.5]"
def test_variable_faltante_lanza_error():
with pytest.raises(PlantillaError, match="Falta 'x'"):
renderizar("{{x}}", {})
def test_seccion_faltante_tambien_lanza_error():
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
renderizar("{{#opcional}}x{{/opcional}}", {})
with pytest.raises(PlantillaError, match="Falta 'opcional'"):
renderizar("{{^opcional}}x{{/opcional}}", {})
def test_seccion_de_lista_se_repite_y_ve_el_contexto_externo():
texto = "{{titulo}}:{{#items}}[{{titulo}}-{{n}}]{{/items}}"
assert renderizar(texto, {"titulo": "T", "items": [{"n": 1}, {"n": 2}]}) == "T:[T-1][T-2]"
def test_lista_vacia_no_muestra_nada_y_la_invertida_si():
texto = "{{#items}}x{{/items}}{{^items}}vacío{{/items}}"
assert renderizar(texto, {"items": []}) == "vacío"
assert renderizar(texto, {"items": [{}]}) == "x"
@pytest.mark.parametrize("valor, visible", [(True, True), (False, False), (None, False), ("", False), ("a", True), (0, False)])
def test_seccion_booleana(valor, visible):
texto = "{{#f}}SI{{/f}}{{^f}}NO{{/f}}"
assert renderizar(texto, {"f": valor}) == ("SI" if visible else "NO")
def test_seccion_con_texto_muestra_una_vez_y_la_variable_sigue_disponible():
"""Caso de ``fecha_estimada_pago``: la sección usa un texto como condición y lo imprime dentro."""
texto = "{{#pago}}Pago: {{pago}}{{/pago}}"
assert renderizar(texto, {"pago": "18/11/2026"}) == "Pago: 18/11/2026"
assert renderizar(texto, {"pago": None}) == ""
def test_secciones_con_el_mismo_nombre_seguidas_en_una_linea():
"""Caso de ``conforme``: ``{{#conforme}}..{{/conforme}}{{^conforme}}..{{/conforme}}``."""
texto = "{{#guias}}<{{#ok}}Conforme{{/ok}}{{^ok}}No coincide{{/ok}}>{{/guias}}"
assert renderizar(texto, {"guias": [{"ok": True}, {"ok": False}]}) == "<Conforme><No coincide>"
def test_secciones_anidadas():
texto = "{{#obs}}{{t}}({{#filas}}{{k}}={{v}};{{/filas}}){{/obs}}"
contexto = {"obs": [{"t": "A", "filas": [{"k": "x", "v": 1}, {"k": "y", "v": 2}]}, {"t": "B", "filas": []}]}
assert renderizar(texto, contexto) == "A(x=1;y=2;)B()"
def test_seccion_sin_cerrar_o_cierre_sin_abrir():
with pytest.raises(PlantillaError, match="no se cierra"):
renderizar("{{#a}}x", {"a": True})
with pytest.raises(PlantillaError, match="no corresponde"):
renderizar("x{{/a}}", {"a": True})
with pytest.raises(PlantillaError, match="no corresponde"):
renderizar("{{#a}}{{#b}}x{{/a}}{{/b}}", {"a": True, "b": True})
def test_llaves_sueltas_de_css_no_se_confunden_con_etiquetas():
assert renderizar("a{color:red;}{{x}}", {"x": "1"}) == "a{color:red;}1"
def test_nombres_usados():
assert plantillas.nombres_usados("{{a}}{{#b}}{{c}}{{/b}}{{^d}}x{{/d}}") == {"a", "b", "c", "d"}
# ============================================================================
# Plantillas reales con los ejemplos de plantilla/ejemplos.json
# ============================================================================
ESCENARIOS = sorted(_ejemplos())
def _claves(obj) -> set[str]:
if isinstance(obj, dict):
return set(obj) | {c for v in obj.values() for c in _claves(v)}
if isinstance(obj, list):
return {c for v in obj for c in _claves(v)}
return set()
@pytest.mark.parametrize("escenario", ESCENARIOS)
def test_todos_los_escenarios_se_renderizan_completos(escenario):
ejemplo = _ejemplos()[escenario]
html = plantillas.renderizar_plantilla(ejemplo["plantilla"], ejemplo["contexto"])
assert "{{" not in html and "}}" not in html # nada sin resolver
assert ejemplo["contexto"]["codigo_seguimiento"] in html
assert html.lstrip().startswith("<!DOCTYPE html>")
@pytest.mark.parametrize("nombre", ["aceptado", "rechazado", "fuera-de-linea"])
def test_los_ejemplos_cubren_las_variables_de_la_plantilla(nombre):
"""Si la plantilla pide algo nuevo (o un ejemplo sobra), el desfase se nota aquí."""
usados = plantillas.nombres_usados(plantillas.cargar_plantilla(nombre))
ejemplos = [e["contexto"] for e in _ejemplos().values() if e["plantilla"] == nombre]
assert ejemplos, f"No hay ejemplos para la plantilla {nombre}"
cubiertos = set().union(*(_claves(c) for c in ejemplos)) # el ejemplo sin guías no trae las claves de cada guía
assert usados <= cubiertos, f"Ningún ejemplo trae: {sorted(usados - cubiertos)}"
for contexto in ejemplos:
sobran = _claves(contexto) - usados
assert not sobran, f"Sobran en el ejemplo: {sorted(sobran)}"
def test_razon_social_con_caracteres_especiales_se_escapa():
ejemplo = _ejemplos()["rechazado_una_observacion"]
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
assert "&lt;Lima&gt;" in html and "&quot;EL RÁPIDO&quot;" in html
assert "<Lima>" not in html
def test_aceptado_muestra_pago_estimado_solo_si_hay_fecha():
ejemplos = _ejemplos()
con = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_con_pago"]["contexto"])
sin = plantillas.renderizar_plantilla("aceptado", ejemplos["aceptado_sin_pago"]["contexto"])
assert "Pago estimado" in con and "18/11/2026" in con
assert "Pago estimado" not in sin
def test_rechazado_numera_las_observaciones_y_marca_las_guias():
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
assert "OBSERVACIÓN 1" in html and "OBSERVACIÓN 2" in html
assert "OBSERVACIÓN 3" not in html
assert html.count("Conforme") == 1 and html.count("No coincide") == 1
assert "Rechazado · 2 observaciones" in html
def test_enlaces_de_contacto_llevan_el_codigo_y_el_comprobante():
contexto = _ejemplos()["rechazado_dos_observaciones"]["contexto"]
html = plantillas.renderizar_plantilla("rechazado", contexto)
assert f"mailto:{contexto['contacto_correo']}?subject=Consulta%20F002-486%20ABR-261019-00045" in html
assert f'href="mailto:{contexto["envio_correo"]}"' in html
def test_rechazo_sin_comprobante_oculta_lo_que_no_existe():
ejemplo = _ejemplos()["rechazado_sin_comprobante"]
html = plantillas.renderizar_plantilla("rechazado", ejemplo["contexto"])
assert "Guías revisadas" not in html # sin tabla de guías vacía
assert "Comprobante</td>" not in html # sin la fila "Comprobante —"
assert "RUC —" not in html and html.count("RUC") == 1 # solo el RUC del pie (empresa)
assert "No pudimos registrar su comprobante" in html
def test_rechazo_con_comprobante_si_muestra_guias_ruc_y_fila_comprobante():
html = plantillas.renderizar_plantilla("rechazado", _ejemplos()["rechazado_dos_observaciones"]["contexto"])
assert "Guías revisadas" in html and "RUC 20537929520" in html and "Comprobante</td>" in html
def test_fuera_de_linea_dice_que_quedo_en_cola_y_no_pide_reenviar():
html = plantillas.renderizar_plantilla("fuera-de-linea", _ejemplos()["fuera_de_linea"]["contexto"])
assert "EN COLA" in html and "Recibimos su factura" in html
assert "temporalmente fuera de línea" in html and "No necesita reenviarla" in html
assert "Guías revisadas" not in html and "Pago estimado" not in html and "ACEPTADO" not in html
assert "ABR-261019-00048" in html and "{{" not in html
+295
View File
@@ -0,0 +1,295 @@
import logging
import pytest
import observaciones as obs
import outlook_utilities
import plantillas
import respuesta_proveedor as rp
import seguimiento
FACTURA = {
"serie": "F002", "numero": "486", "tipo": "01", "ruc_emisor": "20537929520",
"razon_social_emisor": "TRANSPORTES SÁNCHEZ & HIJOS S.A.C.",
"lineas": [
{"total_linea": 624.38, "guias_remision": [{"serie": "T001", "numero": "1"}, {"serie": "T001", "numero": "2"}]},
{"total_linea": 100.0, "guias_remision": [{"serie": "T001", "numero": "3"}]},
],
}
RESULTADOS = [
{"ok": True, "guias_lt": [{"guia": {"serie": "T001", "numero": "1"}}, {"guia": {"serie": "T001", "numero": "2"}}]},
{"ok": False, "guias_lt": [{"guia": {"serie": "T001", "numero": "3"}}]},
]
EMAIL = {"id": "c1", "fecha": "2026-10-14 10:35:00", "subject": "Factura", "sender": "prov@empresa.com"}
@pytest.fixture
def registro():
return seguimiento.registrar_comprobante("c1", "20537929520", "01", "F002", "486")
# ============================================================================
# Configuración
# ============================================================================
@pytest.mark.parametrize("valor, esperado", [(None, "prueba"), ("", "prueba"), ("real", "real"), (" REAL ", "real"),
("prueba", "prueba"), ("produccion", "prueba")])
def test_modo(monkeypatch, valor, esperado):
if valor is not None:
monkeypatch.setenv("NOTIFICACION_MODO", valor)
assert rp.modo() == esperado
def test_equipo(monkeypatch):
assert rp.equipo() == rp.EQUIPO_POR_DEFECTO
monkeypatch.setenv("NOTIFICACION_EQUIPO", " a@x.com ; b@x.com,c@x.com ,, ")
assert rp.equipo() == ["a@x.com", "b@x.com", "c@x.com"]
def test_contacto_usa_el_buzon_si_no_se_define(monkeypatch):
assert rp.contacto_correo() == "buzon@aberio.com"
monkeypatch.setenv("NOTIFICACION_CONTACTO", "ayuda@aberio.com")
assert rp.contacto_correo() == "ayuda@aberio.com"
# ============================================================================
# Protecciones
# ============================================================================
BUZON = "buzon@aberio.com"
def _correo(**kv):
return {**EMAIL, **kv}
@pytest.mark.parametrize("sender", [None, "", "Desconocido"])
def test_sin_remitente(sender):
assert "desconocido" in rp.motivo_omision(_correo(sender=sender), BUZON)
@pytest.mark.parametrize("sender", ["test@local", "sin-arroba", "a@b", "a b@c.com"])
def test_direcciones_no_validas(sender):
assert "no válida" in rp.motivo_omision(_correo(sender=sender), BUZON)
def test_el_propio_buzon_aunque_cambie_la_capitalizacion():
assert "propio buzón" in rp.motivo_omision(_correo(sender="Buzon@ABERIO.com"), BUZON)
@pytest.mark.parametrize("sender", ["no-reply@x.com", "noreply@x.com", "No_Reply@x.com", "donotreply@x.com",
"do-not-reply@x.com", "mailer-daemon@x.com", "postmaster@x.com", "bounces@x.com",
"no-responder@x.com"])
def test_direcciones_automaticas(sender):
assert "automática" in rp.motivo_omision(_correo(sender=sender), BUZON)
def test_una_direccion_normal_con_palabras_parecidas_si_se_responde():
assert rp.motivo_omision(_correo(sender="reply.compras@empresa.com"), BUZON) is None
assert rp.motivo_omision(_correo(sender="ventas@empresa.com"), BUZON) is None
def test_mensaje_automatico():
assert "automático" in rp.motivo_omision(_correo(es_automatico=True), BUZON)
def test_respuestas_a_notificaciones_solo_se_omiten_si_no_hay_comprobante():
respuesta = _correo(subject="Re: Aceptado · Factura F002-1 · ABR-261019-00042")
assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=True) is not None
assert rp.motivo_omision(respuesta, BUZON, sin_comprobante=False) is None # con comprobante se procesa normal
assert rp.motivo_omision(_correo(subject="RV: documentos"), BUZON, sin_comprobante=True) is not None
assert rp.motivo_omision(_correo(subject="Factura ABR-261019-00042"), BUZON, sin_comprobante=True) is not None
assert rp.motivo_omision(_correo(subject="Factura de octubre"), BUZON, sin_comprobante=True) is None
# ============================================================================
# Contextos
# ============================================================================
def test_guias_el_importe_de_la_linea_va_una_vez():
filas = rp.guias_para_plantilla(FACTURA, RESULTADOS)
assert filas == [
{"guia": "T001-1", "importe_factura": "S/ 624.38", "conforme": True},
{"guia": "T001-2", "importe_factura": "incluida en la línea", "conforme": True},
{"guia": "T001-3", "importe_factura": "S/ 100.00", "conforme": False},
]
def test_guias_sin_resultado_de_validacion_usa_las_del_xml_como_no_conformes():
filas = rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": [{"serie": "T001", "numero": "9"}]}]}, [])
assert filas == [{"guia": "T001-9", "importe_factura": "S/ 5.00", "conforme": False}]
def test_linea_sin_guias_no_genera_filas():
assert rp.guias_para_plantilla({"lineas": [{"total_linea": 5, "guias_remision": []}]}, [{"ok": False, "guias_lt": []}]) == []
@pytest.mark.parametrize("vence, esperado", [("2026-11-13", "13/11/2026"), ("2026-10-14", None), ("2026-10-01", None)])
def test_fecha_estimada_de_pago_solo_si_hay_plazo(registro, vence, esperado):
draft = {"DocDate": "2026-10-14", "DocDueDate": vence}
contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, draft)
assert contexto["fecha_estimada_pago"] == esperado
def test_contexto_aceptado(registro):
contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {})
assert contexto["asunto"] == f"Aceptado · Factura F002-486 · {registro['codigo_seguimiento']}"
assert contexto["recepcion_fecha"] == "14/10/2026" and contexto["recepcion_hora"] == "10:35"
assert contexto["comprobante_tipo"] == "Factura electrónica" and contexto["proveedor_ruc"] == "20537929520"
assert contexto["fecha_estimada_pago"] is None
def test_contexto_rechazo_numera_las_observaciones(registro):
lista = [obs.guia_no_encontrada("T001-3"), obs.precio_no_coincide(2, 100, 120, ["T001-3"], True)]
contexto = rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista)
assert contexto["observaciones_texto"] == "2 observaciones"
assert [o["numero"] for o in contexto["observaciones"]] == [1, 2]
assert contexto["observaciones"][0]["filas"] == [{"etiqueta": "Guía", "valor": "T001-3"}]
assert contexto["tiene_guias"] is True and contexto["preheader"].startswith("Tiene 2 observaciones.")
def test_contexto_rechazo_de_correo_sin_comprobante(registro):
contexto = rp.contexto_rechazo_correo(EMAIL, registro, [obs.sin_pdf_xml(1)])
assert contexto["asunto"] == f"Rechazado · Comprobante no recibido · {registro['codigo_seguimiento']}"
assert contexto["comprobante_numero"] is None and contexto["proveedor_ruc"] is None
assert contexto["proveedor_razon_social"] == "prov@empresa.com"
assert contexto["guias"] == [] and contexto["tiene_guias"] is False
def test_los_tres_contextos_renderizan_sin_variables_faltantes(registro):
lista = [obs.sin_pdf_xml(1)]
for plantilla, contexto in [
("aceptado", rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {"DocDate": "2026-10-14", "DocDueDate": "2026-11-13"})),
("rechazado", rp.contexto_rechazo_factura(EMAIL, registro, FACTURA, RESULTADOS, lista)),
("rechazado", rp.contexto_rechazo_correo(EMAIL, registro, lista)),
]:
html = plantillas.renderizar_plantilla(plantilla, contexto)
assert "{{" not in html and registro["codigo_seguimiento"] in html
def test_razon_social_con_ampersand_sale_escapada(registro):
contexto = rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {})
assert "SÁNCHEZ &amp; HIJOS" in plantillas.renderizar_plantilla("aceptado", contexto)
# ============================================================================
# responder()
# ============================================================================
class _Enviados(list):
"""Correos que recibió Graph; ``ok`` controla si Graph los acepta."""
ok = True
@pytest.fixture
def graph(monkeypatch):
enviados = _Enviados()
def enviar(token, desde, para, asunto, html, adjuntos=None, cc_recipients=None, bcc_recipients=None):
enviados.append({"para": para, "bcc": bcc_recipients, "asunto": asunto})
return enviados.ok
monkeypatch.setattr(outlook_utilities, "send_email", enviar)
monkeypatch.setenv("NOTIFICACION_EQUIPO", "eq@aberio.com")
return enviados
def _contexto(registro):
return rp.contexto_aceptado(EMAIL, registro, FACTURA, RESULTADOS, {})
def test_responder_en_modo_real(graph, registro, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada"
assert graph == [{"para": ["prov@empresa.com"], "bcc": ["eq@aberio.com"], "asunto": _contexto(registro)["asunto"]}]
fila = seguimiento.obtener(registro["codigo_seguimiento"])
assert (fila["notificacion_estado"], fila["notificacion_destinatario"]) == ("enviada", "prov@empresa.com")
def test_responder_en_modo_prueba(graph, registro):
assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "enviada"
(envio,) = graph
assert envio["para"] == ["eq@aberio.com"] and envio["bcc"] is None
assert envio["asunto"].startswith("[PRUEBA → prov@empresa.com] Aceptado")
def test_responder_omite_y_anota_el_motivo(graph, registro):
estado = rp.responder("t", BUZON, _correo(sender="no-reply@x.com"), "aceptado", _contexto(registro), registro)
assert estado == "omitida" and graph == []
fila = seguimiento.obtener(registro["codigo_seguimiento"])
assert fila["notificacion_estado"] == "omitida" and "automática" in fila["notificacion_error"]
def test_responder_si_graph_falla(graph, registro):
graph.ok = False
assert rp.responder("t", BUZON, EMAIL, "aceptado", _contexto(registro), registro) == "fallida"
fila = seguimiento.obtener(registro["codigo_seguimiento"])
assert fila["notificacion_estado"] == "fallida" and "Graph" in fila["notificacion_error"]
def test_responder_si_la_plantilla_no_se_puede_armar_no_envia_nada(graph, registro):
contexto = _contexto(registro)
del contexto["codigo_seguimiento"]
assert rp.responder("t", BUZON, EMAIL, "aceptado", contexto, registro) == "fallida"
assert graph == []
assert "codigo_seguimiento" in seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_error"]
def test_el_limite_solo_aplica_a_correos_sin_comprobante(graph, monkeypatch):
import database_utilities
monkeypatch.setenv("NOTIFICACION_MODO", "real")
database_utilities.save_emails([{"id": f"e{n}", "fecha": "2026-10-14 10:00:00", "sender": "prov@empresa.com",
"subject": "s", "has_attach": True} for n in range(5)])
estados = []
for n in range(5):
reg = seguimiento.registrar_correo(f"e{n}")
email = _correo(id=f"e{n}")
estados.append(rp.responder("t", BUZON, email, "rechazado", rp.contexto_rechazo_correo(email, reg, [obs.sin_pdf_xml(1)]),
reg, sin_comprobante=True))
assert estados == ["enviada"] * rp.MAX_RESPUESTAS_CORREO + ["omitida"] * (5 - rp.MAX_RESPUESTAS_CORREO)
def test_encabezado_tecnico(registro):
html = rp.encabezado_tecnico(EMAIL, registro)
assert "prov@empresa.com" in html and registro["codigo_seguimiento"] in html and "prueba" in html
assert "sin código" in rp.encabezado_tecnico(EMAIL, None)
def test_enviar_a_equipo(graph):
assert rp.enviar_a_equipo("t", BUZON, "Asunto", "<p/>", ["a.xml"]) is True
assert graph[0]["para"] == ["eq@aberio.com"]
def test_contexto_fuera_de_linea_renderiza(registro):
contexto = rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA)
assert contexto["asunto"] == f"En cola · Factura F002-486 · {registro['codigo_seguimiento']}"
html = plantillas.renderizar_plantilla("fuera-de-linea", contexto)
assert "{{" not in html and "SÁNCHEZ &amp; HIJOS" in html
def test_responder_con_estado_de_exito_en_espera(graph, registro, monkeypatch):
monkeypatch.setenv("NOTIFICACION_MODO", "real")
estado = rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
registro, estado_exito="en_espera")
assert estado == "en_espera" and graph[0]["para"] == ["prov@empresa.com"]
assert seguimiento.obtener(registro["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
def test_si_el_aviso_falla_queda_fallida_aunque_el_exito_sea_en_espera(graph, registro):
graph.ok = False
assert rp.responder("t", BUZON, EMAIL, "fuera-de-linea", rp.contexto_fuera_de_linea(EMAIL, registro, FACTURA),
registro, estado_exito="en_espera") == "fallida"
+185
View File
@@ -0,0 +1,185 @@
import pytest
import requests
import hana_manager
import sapb1_manager as s
class _Resp:
def __init__(self, status=200, body=None, text=""):
self.status_code, self._body, self.text = status, body if body is not None else {}, text
self.content = b"x"
self.cookies = {}
def json(self):
return self._body
@pytest.fixture(autouse=True)
def sesion_abierta(monkeypatch):
monkeypatch.setitem(s._sap_session, "headers", {"Cookie": "B1SESSION=x"})
# ============================================================================
# Clasificación de errores: caída vs rechazo de SAP
# ============================================================================
@pytest.mark.parametrize("error", [requests.exceptions.ConnectTimeout("t"), requests.exceptions.ReadTimeout("t"),
requests.exceptions.ConnectionError("c")])
def test_timeout_o_sin_conexion_es_sap_no_disponible(monkeypatch, error):
def falla(**k):
raise error
monkeypatch.setattr(s.requests, "request", lambda **k: falla(**k))
with pytest.raises(s.SapNoDisponible):
s.forward_to_sapb1("GET", "Drafts")
@pytest.mark.parametrize("status", [500, 502, 503])
def test_http_5xx_es_sap_no_disponible(monkeypatch, status):
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(status, {"error": {"message": {"value": "caído"}}}))
with pytest.raises(s.SapNoDisponible, match=f"HTTP {status}"):
s.forward_to_sapb1("GET", "Drafts")
def test_http_4xx_es_un_rechazo_no_una_caida(monkeypatch):
monkeypatch.setattr(s.requests, "request", lambda **k: _Resp(400, {"error": {"message": {"value": "ItemCode inexistente"}}}))
with pytest.raises(ConnectionError, match="ItemCode inexistente") as ex:
s.forward_to_sapb1("POST", "Drafts", json_data={})
assert not isinstance(ex.value, s.SapNoDisponible)
def test_sap_no_disponible_sigue_siendo_connection_error():
assert issubclass(s.SapNoDisponible, ConnectionError)
def test_login_sin_conexion_es_caida(monkeypatch):
def falla(*a, **k):
raise requests.exceptions.ConnectTimeout("t")
monkeypatch.setattr(s.requests, "post", falla)
with pytest.raises(s.SapNoDisponible):
s.login()
def test_login_5xx_es_caida_pero_credenciales_malas_no(monkeypatch):
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(503, text="down"))
with pytest.raises(s.SapNoDisponible):
s.login()
monkeypatch.setattr(s.requests, "post", lambda *a, **k: _Resp(401, text="Invalid credentials"))
with pytest.raises(ConnectionError) as ex:
s.login()
assert not isinstance(ex.value, s.SapNoDisponible)
# ============================================================================
# Guías: una caída no se confunde con "no existe"
# ============================================================================
def _forward(respuestas):
"""forward_to_sapb1 simulado: ``respuestas`` es una función (metodo, entidad) -> dict | excepción."""
def forward(method, endpoint_path, **k):
r = respuestas(method, endpoint_path)
if isinstance(r, Exception):
raise r
return _Resp(200, r)
return forward
def test_sl_caido_y_hana_sin_resultados_es_caida_no_guia_inexistente(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
with pytest.raises(s.SapNoDisponible):
s.get_delivery_notes("09", "T001", "1")
def test_sl_caido_pero_hana_encuentra_la_guia_sigue_adelante(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: s.SapNoDisponible("sin conexión")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [{"DocEntry": 7, "Tabla": "ODLN"}])
entradas = s._get_delivery_entries("09", "T001", "1")
assert entradas == [{"DocEntry": 7, "Tabla": "ODLN", "Entidad": "DeliveryNotes"}]
def test_guia_que_de_verdad_no_existe_devuelve_lista_vacia(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: {"value": []}))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
assert s.get_delivery_notes("09", "T001", "1") == []
def test_un_rechazo_normal_de_sl_con_hana_vacio_no_es_caida(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _forward(lambda m, e: ConnectionError("HTTP 400 - mal filtro")))
monkeypatch.setattr(hana_manager, "get_delivery", lambda *a: [])
assert s.get_delivery_notes("09", "T001", "1") == []
def test_una_caida_al_leer_el_documento_se_propaga(monkeypatch):
def respuestas(metodo, entidad):
if entidad == "DeliveryNotes":
return {"value": [{"DocEntry": 7}]}
return s.SapNoDisponible("se cayó a mitad") # GET DeliveryNotes(7)
monkeypatch.setattr(s, "forward_to_sapb1", _forward(respuestas))
with pytest.raises(s.SapNoDisponible):
s.get_delivery_notes("09", "T001", "1")
# ============================================================================
# buscar_factura_existente
# ============================================================================
class _Grabadora:
def __init__(self, existentes):
self.llamadas, self.existentes = [], existentes
def __call__(self, method, endpoint_path, **k):
self.llamadas.append((endpoint_path, k["params"]))
valor = self.existentes.get(endpoint_path)
if isinstance(valor, Exception):
raise valor
return _Resp(200, {"value": [valor] if valor else []})
def test_encuentra_la_factura_de_compra(monkeypatch):
graba = _Grabadora({"PurchaseInvoices": {"DocEntry": 10, "DocNum": 5}})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") == \
{"entidad": "PurchaseInvoices", "DocEntry": 10, "DocNum": 5}
entidad, params = graba.llamadas[0]
assert len(graba.llamadas) == 1 # no hace falta mirar los drafts
for trozo in ("CardCode eq 'PL20537929520'", "U_BPP_MDTD eq '01'", "U_BPP_MDSD eq 'F002'",
"U_BPP_MDCD eq '486'", "Cancelled eq 'tNO'"):
assert trozo in params["$filter"]
def test_si_no_es_factura_busca_en_los_drafts(monkeypatch):
graba = _Grabadora({"Drafts": {"DocEntry": 3, "DocNum": 3}})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
assert s.buscar_factura_existente("20537929520", "01", "F002", "486")["entidad"] == "Drafts"
assert [l[0] for l in graba.llamadas] == ["PurchaseInvoices", "Drafts"]
assert "DocObjectCode eq 'oPurchaseInvoices'" in graba.llamadas[1][1]["$filter"]
def test_si_no_existe_devuelve_none(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({}))
assert s.buscar_factura_existente("20537929520", "01", "F002", "486") is None
def test_las_comillas_se_escapan_en_el_filtro(monkeypatch):
graba = _Grabadora({})
monkeypatch.setattr(s, "forward_to_sapb1", graba)
s.buscar_factura_existente("20537929520", "01", "F0'02", "4' or '1'='1")
filtro = graba.llamadas[0][1]["$filter"]
assert "U_BPP_MDSD eq 'F0''02'" in filtro and "U_BPP_MDCD eq '4'' or ''1''=''1'" in filtro
def test_una_caida_al_buscar_se_propaga(monkeypatch):
monkeypatch.setattr(s, "forward_to_sapb1", _Grabadora({"PurchaseInvoices": s.SapNoDisponible("caído")}))
with pytest.raises(s.SapNoDisponible):
s.buscar_factura_existente("20537929520", "01", "F002", "486")
+105
View File
@@ -147,3 +147,108 @@ def test_la_tabla_emails_existente_no_se_modifica():
database_utilities.init_db()
assert seguimiento.registrar_correo("correo-1")["codigo_seguimiento"].startswith("ABR-")
def test_registrar_notificacion_guarda_el_destinatario():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
seguimiento.registrar_notificacion(r["codigo_seguimiento"], "enviada", destinatario="a@x.com, b@x.com")
assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_destinatario"] == "a@x.com, b@x.com"
def _correo_con_respuesta(email_id, sender, estado="enviada", comprobante=False):
database_utilities.save_emails([{"id": email_id, "fecha": "2026-10-14 10:00:00", "sender": sender,
"subject": "s", "has_attach": True}])
r = (seguimiento.registrar_comprobante(email_id, *COMPROBANTE[:2], "F002", email_id) if comprobante
else seguimiento.registrar_correo(email_id))
seguimiento.registrar_notificacion(r["codigo_seguimiento"], estado)
return r
def test_contar_respuestas_solo_cuenta_enviadas_a_correos_sin_comprobante_de_ese_remitente():
_correo_con_respuesta("e1", "Prov@Empresa.com") # cuenta (sin distinguir mayúsculas)
_correo_con_respuesta("e2", "prov@empresa.com") # cuenta
_correo_con_respuesta("e3", "prov@empresa.com", estado="omitida") # no: no se envió
_correo_con_respuesta("e4", "prov@empresa.com", estado="fallida") # no: no se envió
_correo_con_respuesta("e5", "otro@empresa.com") # no: otro remitente
_correo_con_respuesta("e6", "prov@empresa.com", comprobante=True) # no: tiene comprobante
assert seguimiento.contar_respuestas_correo("prov@empresa.com") == 2
assert seguimiento.contar_respuestas_correo("nadie@empresa.com") == 0
def test_contar_respuestas_ignora_las_anteriores_a_la_ventana():
r = _correo_con_respuesta("e1", "prov@empresa.com")
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.execute("UPDATE comprobantes SET notificacion_fecha = '2020-01-01 00:00:00' WHERE codigo_seguimiento = ?",
(r["codigo_seguimiento"],))
conn.commit()
conn.close()
assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24) == 0
assert seguimiento.contar_respuestas_correo("prov@empresa.com", horas=24 * 365 * 10) == 1
def test_init_db_agrega_la_columna_a_una_tabla_creada_antes():
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.executescript("""
DROP TABLE comprobantes;
CREATE TABLE comprobantes (
id INTEGER PRIMARY KEY AUTOINCREMENT, codigo_seguimiento TEXT UNIQUE, email_id TEXT NOT NULL,
ruc_emisor TEXT, tipo TEXT, serie TEXT, numero TEXT, estado TEXT NOT NULL DEFAULT 'pendiente',
observaciones TEXT, doc_entry INTEGER, veces_recibido INTEGER NOT NULL DEFAULT 1,
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente', notificacion_error TEXT, notificacion_fecha TEXT,
creado_en TEXT NOT NULL, actualizado_en TEXT NOT NULL);
INSERT INTO comprobantes (codigo_seguimiento, email_id, creado_en, actualizado_en)
VALUES ('ABR-261001-00001', 'viejo', '2026-10-01 10:00:00', '2026-10-01 10:00:00');
""")
conn.commit()
conn.close()
database_utilities.init_db()
assert seguimiento.obtener("ABR-261001-00001")["email_id"] == "viejo" # el dato se conserva
seguimiento.registrar_notificacion("ABR-261001-00001", "enviada", destinatario="a@x.com")
assert seguimiento.obtener("ABR-261001-00001")["notificacion_destinatario"] == "a@x.com"
def test_registrar_intento_cuenta_de_uno_en_uno():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
assert r["intentos"] == 0
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 1
assert seguimiento.registrar_intento(r["codigo_seguimiento"]) == 2
assert seguimiento.obtener(r["codigo_seguimiento"])["intentos"] == 2
def test_registrar_intento_de_un_codigo_inexistente():
with pytest.raises(LookupError):
seguimiento.registrar_intento("ABR-000000-99999")
def test_en_espera_es_un_estado_de_notificacion_valido():
r = seguimiento.registrar_comprobante("correo-1", *COMPROBANTE)
seguimiento.registrar_notificacion(r["codigo_seguimiento"], "en_espera", destinatario="a@x.com")
assert seguimiento.obtener(r["codigo_seguimiento"])["notificacion_estado"] == "en_espera"
def test_init_db_agrega_la_columna_intentos_a_una_tabla_anterior():
conn = sqlite3.connect(database_utilities.DB_NAME)
conn.executescript("""
DROP TABLE comprobantes;
CREATE TABLE comprobantes (
id INTEGER PRIMARY KEY AUTOINCREMENT, codigo_seguimiento TEXT UNIQUE, email_id TEXT NOT NULL,
ruc_emisor TEXT, tipo TEXT, serie TEXT, numero TEXT, estado TEXT NOT NULL DEFAULT 'pendiente',
observaciones TEXT, doc_entry INTEGER, veces_recibido INTEGER NOT NULL DEFAULT 1,
notificacion_estado TEXT NOT NULL DEFAULT 'pendiente', notificacion_error TEXT, notificacion_fecha TEXT,
notificacion_destinatario TEXT, creado_en TEXT NOT NULL, actualizado_en TEXT NOT NULL);
INSERT INTO comprobantes (codigo_seguimiento, email_id, creado_en, actualizado_en)
VALUES ('ABR-261001-00001', 'viejo', '2026-10-01 10:00:00', '2026-10-01 10:00:00');
""")
conn.commit()
conn.close()
database_utilities.init_db()
assert seguimiento.obtener("ABR-261001-00001")["intentos"] == 0 # el valor por defecto se aplica a lo existente
assert seguimiento.registrar_intento("ABR-261001-00001") == 1
-3
View File
@@ -1,3 +0,0 @@
{
"ngrok":"2L3lPgEfu0YX5O0xPeqm4FKgPwP_PwxXGpJn3Uow7JwiS12h"
}
+116
View File
@@ -0,0 +1,116 @@
"""
Renderiza las plantillas de correo (plantilla/plantilla-*.html) con los datos de
ejemplo de plantilla/ejemplos.json, para revisarlas en el navegador o enviarse
un correo de prueba.
Uso (desde cualquier directorio):
python tools/previsualizar_plantillas.py --listar
python tools/previsualizar_plantillas.py --abrir # todos los escenarios
python tools/previsualizar_plantillas.py --escenario aceptado_con_pago --abrir
python tools/previsualizar_plantillas.py --escenario rechazado_dos_observaciones --enviar yo@dominio.com otro@dominio.com
Los HTML se guardan en tools/previews/ (ignorada por git). Para probar otros textos,
edita plantilla/ejemplos.json: el renderizado es estricto y avisa si falta algún dato.
``--enviar`` manda el correo real con Microsoft Graph desde el buzón EMAIL del .env,
con el asunto prefijado con "[PRUEBA]". Exige indicar el escenario (o ``todos``).
En el correo enviado el logo queda vacío mientras ``logo_url`` no tenga una URL pública.
"""
import argparse
import json
import os
import sys
import webbrowser
RAIZ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
sys.path.insert(0, RAIZ)
from dotenv import load_dotenv
import plantillas
RUTA_EJEMPLOS = os.path.join(plantillas.CARPETA_PLANTILLAS, "ejemplos.json")
SALIDA_POR_DEFECTO = os.path.join(os.path.dirname(os.path.abspath(__file__)), "previews")
# Solo para la vista previa local: los clientes de correo no muestran imágenes data:
LOGO_PROVISIONAL = (
"data:image/svg+xml;utf8,"
"<svg xmlns='http://www.w3.org/2000/svg' width='117' height='28'>"
"<rect width='117' height='28' fill='%23FF6900'/>"
"<text x='58' y='19' font-family='Arial' font-size='14' font-weight='bold' fill='%23282828' text-anchor='middle'>Aberio</text>"
"</svg>"
)
def cargar_ejemplos() -> dict:
with open(RUTA_EJEMPLOS, encoding="utf-8") as f:
return json.load(f)
def seleccionar(ejemplos: dict, escenario: str | None) -> dict:
if escenario in (None, "todos"):
return ejemplos
if escenario not in ejemplos:
sys.exit(f"❌ Escenario '{escenario}' no existe. Disponibles: {', '.join(ejemplos)}")
return {escenario: ejemplos[escenario]}
def renderizar(ejemplo: dict, para_navegador: bool) -> str:
contexto = dict(ejemplo["contexto"])
if para_navegador and not contexto.get("logo_url"):
contexto["logo_url"] = LOGO_PROVISIONAL
return plantillas.renderizar_plantilla(ejemplo["plantilla"], contexto)
def main() -> None:
parser = argparse.ArgumentParser(description="Vista previa y envío de prueba de las plantillas de correo.")
parser.add_argument("--listar", action="store_true", help="Lista los escenarios disponibles y termina.")
parser.add_argument("--escenario", "-e", help="Nombre del escenario, o 'todos'. Por defecto, todos (obligatorio con --enviar).")
parser.add_argument("--salida", default=SALIDA_POR_DEFECTO, help="Carpeta donde se guardan los HTML.")
parser.add_argument("--abrir", action="store_true", help="Abre cada HTML en el navegador.")
parser.add_argument("--enviar", metavar="CORREO", nargs="+", help="Envía el escenario a estos correos como prueba (uno o varios).")
args = parser.parse_args()
ejemplos = cargar_ejemplos()
if args.listar:
for nombre, e in ejemplos.items():
print(f"{nombre:32} ({e['plantilla']}) {e['contexto']['asunto']}")
return
if args.enviar and not args.escenario:
sys.exit("❌ Con --enviar indica el escenario: --escenario <nombre> (o --escenario todos).")
elegidos = seleccionar(ejemplos, args.escenario)
os.makedirs(args.salida, exist_ok=True)
for nombre, ejemplo in elegidos.items():
ruta = os.path.join(args.salida, f"{nombre}.html")
with open(ruta, "w", encoding="utf-8") as f:
f.write(renderizar(ejemplo, para_navegador=True))
print(f"📄 {os.path.relpath(ruta)}")
if args.abrir:
webbrowser.open("file://" + os.path.abspath(ruta))
if args.enviar:
import outlook_utilities
load_dotenv(dotenv_path=os.path.join(RAIZ, ".env"))
buzon = os.getenv("EMAIL", "")
if not buzon:
sys.exit("❌ EMAIL no configurado en .env")
try:
token = outlook_utilities.get_access_token()
except Exception as ex:
sys.exit(f"❌ No se pudo obtener el token de Microsoft Graph, no se envió nada:\n {ex}")
if not token:
sys.exit("❌ No se pudo obtener el token de Microsoft Graph, no se envió nada")
for nombre, ejemplo in elegidos.items():
asunto = f"[PRUEBA] {ejemplo['contexto']['asunto']}"
ok = outlook_utilities.send_email(token, buzon, args.enviar, asunto, renderizar(ejemplo, para_navegador=False))
print(f"{'✉️ Enviado' if ok else '❌ Falló'}: {asunto} → {', '.join(args.enviar)}")
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
"""
Comprueba, sin modificar nada, que el servidor puede trabajar: configuración del .env, credenciales de
Microsoft Graph y lectura del buzón, SAP Service Layer, SAP HANA y la ruta de red de facturación.
Uso (desde cualquier carpeta):
python tools/verificar_conexiones.py
Termina con código 1 si alguna comprobación falla. No imprime secretos.
"""
import logging
import os
import re
import sys
from datetime import datetime, timedelta
RAIZ = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
sys.path.insert(0, RAIZ)
from dotenv import load_dotenv
load_dotenv(dotenv_path=os.path.join(RAIZ, ".env"))
logging.disable(logging.CRITICAL) # solo interesa el resultado de cada comprobación
import config_validator
import hana_manager
import outlook_utilities
import sapb1_manager
fallos = 0
def resultado(ok: bool, nombre: str, detalle: str = "") -> None:
global fallos
fallos += 0 if ok else 1
print(f"{'✅' if ok else '❌'} {nombre}{(': ' + detalle) if detalle else ''}")
def resumen_error(ex: Exception) -> str:
return ", ".join(re.findall(r"AADSTS\d+", str(ex))) or f"{type(ex).__name__}: {str(ex)[:110]}"
# 1. Configuración
errores, avisos = config_validator.validar_configuracion()
resultado(not errores, "Configuración (.env)", "; ".join(errores))
for aviso in avisos:
print(f" ⚠️ {aviso}")
# 2. Microsoft Graph: token y lectura de la bandeja
token = None
try:
token = outlook_utilities.get_access_token()
resultado(bool(token), "Microsoft Graph: token")
except Exception as ex:
resultado(False, "Microsoft Graph: token", resumen_error(ex))
if token:
try:
hoy = datetime.now()
correos = outlook_utilities.get_emails(os.getenv("EMAIL", ""), token, (hoy - timedelta(days=1)).strftime("%Y-%m-%d"), hoy.strftime("%Y-%m-%d"))
resultado(True, "Microsoft Graph: lectura de la bandeja", f"{len(correos)} correos en el último día")
except Exception as ex:
resultado(False, "Microsoft Graph: lectura de la bandeja", resumen_error(ex))
# 3. SAP Service Layer
try:
resultado(True, "SAP Service Layer", f"{len(sapb1_manager.get_zonas())} zonas")
except Exception as ex:
resultado(False, "SAP Service Layer", resumen_error(ex))
# 4. SAP HANA
conexion = hana_manager.get_connection()
resultado(conexion is not None, "SAP HANA")
if conexion is not None:
conexion.close()
# 5. Ruta de red de facturación electrónica (opcional)
ruta = os.getenv("RUTA_RED_FACTURACION", "").strip()
if ruta:
existe = os.path.isdir(ruta)
resultado(existe and os.access(ruta, os.W_OK), "Ruta de red de facturación", "" if existe else "no existe o no es accesible con esta cuenta")
else:
print(" ⚠️ RUTA_RED_FACTURACION no definida: no se copia a la red (no se comprueba)")
print()
print("Todo listo." if not fallos else f"{fallos} comprobación(es) con fallo.")
sys.exit(1 if fallos else 0)