Compare commits
17
Commits
v1.1.0
...
50324cca6e
No files matched your search
@@ -26,6 +26,7 @@ HANA_PASSWORD =
|
|||||||
HANA_SCHEMA =
|
HANA_SCHEMA =
|
||||||
|
|
||||||
# --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) ---
|
# --- Microsoft Graph / Outlook (MSAL, app registration en Azure AD) ---
|
||||||
|
# Cómo obtener estos valores: docs/configurar_msal.md
|
||||||
CLIENT_ID =
|
CLIENT_ID =
|
||||||
CLIENT_SECRET =
|
CLIENT_SECRET =
|
||||||
TENANT_ID =
|
TENANT_ID =
|
||||||
@@ -33,3 +34,14 @@ VERIFY_SSL = false
|
|||||||
|
|
||||||
# --- Logging (ver logger_manager.py). Opcional, por defecto INFO ---
|
# --- Logging (ver logger_manager.py). Opcional, por defecto INFO ---
|
||||||
LOG_LEVEL = INFO
|
LOG_LEVEL = INFO
|
||||||
|
|
||||||
|
# --- Respuesta al proveedor (aceptado / rechazado), ver docs/respuesta_proveedor.md ---
|
||||||
|
# prueba (por defecto): la respuesta va al equipo con asunto "[PRUEBA → remitente]", NO al proveedor.
|
||||||
|
# real: la respuesta va a quien envió el correo, con copia oculta al equipo.
|
||||||
|
NOTIFICACION_MODO = prueba
|
||||||
|
# Direcciones internas (coma o punto y coma): reciben el correo técnico y las respuestas en modo prueba.
|
||||||
|
NOTIFICACION_EQUIPO = equipo1@dominio.com,equipo2@dominio.com
|
||||||
|
# Contacto del botón "Escribir a Aberio" (por defecto, el buzón EMAIL).
|
||||||
|
NOTIFICACION_CONTACTO =
|
||||||
|
# URL pública (https) del logo que se muestra en el correo. Vacío = sin logo.
|
||||||
|
LOGO_URL =
|
||||||
@@ -10,4 +10,8 @@ adjuntos/
|
|||||||
resultado/
|
resultado/
|
||||||
emails.db
|
emails.db
|
||||||
.env.dev
|
.env.dev
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
procesos/
|
procesos/
|
||||||
|
tools/previews/
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Historial de cambios
|
||||||
|
|
||||||
|
Las versiones siguen [SemVer](https://semver.org/lang/es/) y se publican como etiquetas de git (`v1.2.1`). La versión vigente está en `pyproject.toml` y se muestra en el log al arrancar.
|
||||||
|
|
||||||
|
## [1.2.1] — 2026-10-22
|
||||||
|
|
||||||
|
### Corregido
|
||||||
|
- **Tolerancia de importes:** la diferencia entre el importe de la factura y el de SAP se redondea a 2 decimales (lo que muestra un comprobante) y se admite hasta 1 céntimo (`pipeline_utils.importes_coinciden`). Antes se comparaba `abs(a - b) < 0.01` sin redondear, y por la coma flotante el mismo céntimo se aceptaba o se rechazaba según el importe: por ejemplo 431.41 contra 431.40 se rechazaba y 624.39 contra 624.38 se aceptaba. Con datos reales de SAP, ninguna línea cambió de resultado, así que no se acepta nada que antes se rechazara con razón. También se aplica a la comparación del valor referencial del correo técnico.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- Se elimina `tools/aberio.http`, que llevaba escrita una contraseña de SAP, y se reescribe el historial de git para enmascarar esa contraseña y el token de ngrok. **Aun así deben rotarse**: el historial anterior pudo haberse copiado en otros equipos o servidores.
|
||||||
|
|
||||||
|
## [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.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# descarga_factura_transporte
|
||||||
|
|
||||||
|
Demonio que descarga las facturas de transporte (PDF/XML SUNAT) recibidas en un buzón de Office 365, valida sus precios contra SAP Business One y crea el borrador (Draft) de la factura de compra. Al terminar, notifica el resultado por correo al remitente.
|
||||||
|
|
||||||
|
## Qué hace con cada correo
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
→ 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).
|
||||||
|
|
||||||
|
## Requisitos
|
||||||
|
|
||||||
|
- Python 3.11 o superior.
|
||||||
|
- Acceso a: Microsoft Graph (app registrada en Azure AD), SAP B1 Service Layer y SAP HANA.
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # y completar los valores
|
||||||
|
```
|
||||||
|
|
||||||
|
El `.env` nunca se sube a git. Todas las variables están documentadas en [`.env.example`](.env.example). Al arrancar, `config_validator` termina con un error claro si falta alguna obligatoria.
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.1`); el historial está en [`CHANGELOG.md`](CHANGELOG.md).
|
||||||
|
|
||||||
|
## Pruebas
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]" # instala pytest
|
||||||
|
pytest # ~95 pruebas, unos 3 segundos; no tocan SAP, HANA ni Outlook
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Extracción** (`tests/test_extraccion_correos.py`): corre la extracción real sobre cada correo de `tests/fixtures/correos_prueba/` y la compara con `tests/golden/<caso>.json`. Esos resultados esperados fijan el comportamiento actual; no prueban que sea correcto. Si cambias la extracción a propósito, revisa el diff y regenéralos con `UPDATE_GOLDEN=1 pytest tests/test_extraccion_correos.py`.
|
||||||
|
- **Unitarias**: validación de precios, construcción del draft, notificaciones, guías, copia a red, filtrado de adjuntos y validación del `.env`.
|
||||||
|
- **Flujo completo** (`tests/test_pipeline_flujo.py`): `_procesar_un_correo` con SAP, HANA, Outlook y la BD simulados.
|
||||||
|
|
||||||
|
Los scripts de `tools/` (`procesar_pdfs_prueba.py`, `procesar_xmls_prueba.py`, etc.) son anteriores a la suite: escriben `xml_resultado_*.json` y `pdf_resultado_*.json` dentro de cada carpeta de `tests/fixtures/correos_prueba/`. Esos archivos no los usa ninguna prueba y pueden estar desactualizados. `tools/descargar_correos_prueba.py` **borra y vuelve a descargar** toda esa carpeta de correos reales.
|
||||||
|
|
||||||
|
## Carpetas de salida (ignoradas por git)
|
||||||
|
|
||||||
|
| Ruta | Contenido |
|
||||||
|
|------|-----------|
|
||||||
|
| `adjuntos/` | PDF/XML descargados (`DOWNLOAD_FOLDER`) |
|
||||||
|
| `resultado/` | JSON de datos extraídos y de drafts, para depuración (`OUTPUT_FOLDER`) |
|
||||||
|
| `logs/` | Log con rotación diaria y 30 días de historial. `LOG_LEVEL` controla el nivel |
|
||||||
|
| `emails.db` | Correos ya procesados y comprobantes con su código de seguimiento |
|
||||||
|
|
||||||
|
## Estructura del proyecto
|
||||||
|
|
||||||
|
| Módulo | Rol |
|
||||||
|
|--------|-----|
|
||||||
|
| `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; 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 |
|
||||||
|
| `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 |
|
||||||
|
| `tests/` | Suite pytest, correos de ejemplo (`tests/fixtures/`) y resultados esperados (`tests/golden/`) |
|
||||||
|
|
||||||
|
## Documentación
|
||||||
|
|
||||||
|
| Documento | Tema |
|
||||||
|
|-----------|------|
|
||||||
|
| [`docs/inventario.md`](docs/inventario.md) | Archivos, dependencias, flujo de datos y bugs corregidos |
|
||||||
|
| [`docs/main.md`](docs/main.md) | Demonio y horarios |
|
||||||
|
| [`docs/pipeline.md`](docs/pipeline.md) | Flujo por correo y funciones por módulo |
|
||||||
|
| [`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 |
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
"""Descarga y filtrado de adjuntos de un correo."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import outlook_utilities
|
||||||
|
from pipeline_utils import log, ruta_carpeta
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def descargar_adjuntos(email: dict, email_addr: str, token: str, download_folder: str) -> list[str] | None:
|
||||||
|
"""
|
||||||
|
Descarga todos los adjuntos del correo usando Microsoft Graph.
|
||||||
|
|
||||||
|
Almacena la lista de nombres de archivo en ``email["adjuntos"]``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de nombres de archivo descargados, o None si no hay adjuntos
|
||||||
|
o hubo un error.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Si el correo tiene ``factura.xml`` y ``guia.pdf``::
|
||||||
|
|
||||||
|
adjuntos = descargar_adjuntos(email, email_addr, token, download_folder)
|
||||||
|
# → ["factura.xml", "guia.pdf"]
|
||||||
|
# email["adjuntos"] = ["factura.xml", "guia.pdf"]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
adjuntos = outlook_utilities.download_attachments(
|
||||||
|
email_addr, email["id"], token, download_folder
|
||||||
|
)
|
||||||
|
if adjuntos:
|
||||||
|
email["adjuntos"] = adjuntos
|
||||||
|
return adjuntos
|
||||||
|
return None
|
||||||
|
except Exception as ex:
|
||||||
|
log(f"❌ Error descargando adjuntos: {ex}", severity=logging.ERROR)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def filtrar_y_limpiar(email: dict, download_folder: str) -> bool:
|
||||||
|
"""
|
||||||
|
Filtra los adjuntos conservando solo archivos PDF y XML.
|
||||||
|
|
||||||
|
- Los archivos que no son PDF ni XML se eliminan del disco.
|
||||||
|
- Si no queda ningún PDF/XML válido, se eliminan todos los archivos
|
||||||
|
descargados y se retorna False.
|
||||||
|
- Actualiza ``email["adjuntos"]`` con la lista filtrada.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si hay al menos un PDF o XML, False si no queda ninguno.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Adjuntos originales: ``[img.png, factura.xml, guia.pdf]``::
|
||||||
|
|
||||||
|
ok = filtrar_y_limpiar(email, download_folder)
|
||||||
|
# → img.png eliminado
|
||||||
|
# → ok = True
|
||||||
|
# → email["adjuntos"] = ["factura.xml", "guia.pdf"]
|
||||||
|
"""
|
||||||
|
todos = email.get("adjuntos", [])
|
||||||
|
validos = [a for a in todos if a.lower().endswith((".pdf", ".xml"))]
|
||||||
|
no_validos = [a for a in todos if a not in validos]
|
||||||
|
|
||||||
|
carpeta = ruta_carpeta(download_folder)
|
||||||
|
|
||||||
|
if not validos:
|
||||||
|
for a in todos:
|
||||||
|
ruta = os.path.join(carpeta, a)
|
||||||
|
if os.path.exists(ruta):
|
||||||
|
os.remove(ruta)
|
||||||
|
log("🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
|
||||||
|
return False
|
||||||
|
|
||||||
|
for a in no_validos:
|
||||||
|
ruta = os.path.join(carpeta, a)
|
||||||
|
if os.path.exists(ruta):
|
||||||
|
os.remove(ruta)
|
||||||
|
|
||||||
|
email["adjuntos"] = validos
|
||||||
|
return True
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""
|
||||||
|
Validación de la configuración (.env) al arrancar el proceso.
|
||||||
|
|
||||||
|
Falla rápido con un mensaje claro si falta alguna variable obligatoria, en
|
||||||
|
lugar de descubrirlo a mitad de un ciclo (por ejemplo, adjuntos descargados
|
||||||
|
en la raíz del proyecto por un ``DOWNLOAD_FOLDER`` vacío, o consultas a HANA
|
||||||
|
que devuelven 0 en silencio por credenciales ausentes).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Variables obligatorias, agrupadas por servicio para el mensaje de error.
|
||||||
|
OBLIGATORIAS = {
|
||||||
|
"Pipeline": ["EMAIL", "DOWNLOAD_FOLDER", "OUTPUT_FOLDER"],
|
||||||
|
"Microsoft Graph": ["CLIENT_ID", "CLIENT_SECRET", "TENANT_ID"],
|
||||||
|
"SAP B1 Service Layer": ["SAP_B1_URL", "SAP_B1_COMPANY", "SAP_B1_USER", "SAP_B1_PASSWORD"],
|
||||||
|
"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 = {
|
||||||
|
"VERIFY_SSL": "Microsoft Graph",
|
||||||
|
"SAP_B1_VERIFY_SSL": "SAP B1 Service Layer",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validar_configuracion() -> tuple[list[str], list[str]]:
|
||||||
|
"""
|
||||||
|
Revisa las variables de entorno ya cargadas (tras ``load_dotenv``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[list[str], list[str]]: ``(errores, advertencias)``. Los errores
|
||||||
|
impiden arrancar; las advertencias solo se registran.
|
||||||
|
"""
|
||||||
|
errores: list[str] = []
|
||||||
|
advertencias: list[str] = []
|
||||||
|
|
||||||
|
for servicio, variables in OBLIGATORIAS.items():
|
||||||
|
faltan = [v for v in variables if not os.getenv(v, "").strip()]
|
||||||
|
if faltan:
|
||||||
|
errores.append(f"{servicio}: faltan {', '.join(faltan)}")
|
||||||
|
|
||||||
|
ruta_red = os.getenv("RUTA_RED_FACTURACION", "").strip()
|
||||||
|
ruc_empresa = os.getenv("RUC_EMPRESA", "").strip()
|
||||||
|
if ruta_red and not ruc_empresa:
|
||||||
|
errores.append("Ruta de red: RUTA_RED_FACTURACION está definida pero falta RUC_EMPRESA")
|
||||||
|
elif not ruta_red:
|
||||||
|
advertencias.append(
|
||||||
|
"RUTA_RED_FACTURACION no definida: no se copiarán PDF/XML a la red "
|
||||||
|
"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")
|
||||||
|
|
||||||
|
for variable, servicio in VERIFICACION_SSL.items():
|
||||||
|
if os.getenv(variable, "").strip().lower() != "true":
|
||||||
|
advertencias.append(f"Verificación SSL desactivada para {servicio} ({variable} distinto de 'true')")
|
||||||
|
|
||||||
|
return errores, advertencias
|
||||||
|
|
||||||
|
|
||||||
|
def verificar_configuracion() -> None:
|
||||||
|
"""
|
||||||
|
Registra las advertencias y, si hay errores, los registra y termina el
|
||||||
|
proceso con código 1.
|
||||||
|
"""
|
||||||
|
errores, advertencias = validar_configuracion()
|
||||||
|
|
||||||
|
for a in advertencias:
|
||||||
|
logger.warning(f"⚠️ Configuración: {a}")
|
||||||
|
|
||||||
|
if errores:
|
||||||
|
logger.error("❌ Configuración incompleta en .env:")
|
||||||
|
for e in errores:
|
||||||
|
logger.error(f" - {e}")
|
||||||
|
sys.exit(1)
|
||||||
+49
-2
@@ -1,15 +1,20 @@
|
|||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CONFIGURACIÓN
|
# 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:
|
def init_db() -> None:
|
||||||
"""
|
"""
|
||||||
Inicializa la base de datos SQLite y crea la tabla de correos si no existe.
|
Inicializa la base de datos SQLite y crea las tablas si no existen:
|
||||||
|
``emails`` (correos ya procesados) y ``comprobantes`` (códigos de
|
||||||
|
seguimiento y resultado de cada comprobante, ver ``seguimiento.py``).
|
||||||
"""
|
"""
|
||||||
conn = sqlite3.connect(DB_NAME)
|
conn = sqlite3.connect(DB_NAME)
|
||||||
try:
|
try:
|
||||||
@@ -27,6 +32,48 @@ def init_db() -> None:
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
# Un comprobante se identifica por (ruc_emisor, tipo, serie, numero). Los
|
||||||
|
# correos sin comprobante identificable llevan serie NULL y son únicos por correo.
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS 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,
|
||||||
|
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
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_comprobantes_correo
|
||||||
|
ON comprobantes (email_id) WHERE serie IS NULL
|
||||||
|
''')
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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."
|
||||||
@@ -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"
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Construir Draft SAP B1
|
||||||
|
|
||||||
|
Función: `draft_sap.build_sap_draft(factura, resultados_val)` → dict listo para `POST /Drafts`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["factura + resultados_val<br>(de validate_prices)"] --> B["ItemCode según fecha_emision:<br>C103002-A (mes anterior)<br>C103002 (mes actual)"]
|
||||||
|
B --> B2["CardCode = PL + ruc_emisor<br>sapb1_manager.obtener_condicion_pago(CardCode)<br>(BusinessPartners.PayTermsGrpCode → PaymentTermsTypes)"]
|
||||||
|
B2 --> B3["DocDueDate = fecha_contabilizacion (hoy) +<br>NumberOfAdditionalDays<br>(si es 0, NumberOfAdditionalMonths * 30)"]
|
||||||
|
B3 --> C["Calcular cuotas<br>DocumentInstallments"]
|
||||||
|
|
||||||
|
C --> D{"¿Tiene detracción?"}
|
||||||
|
D -->|Sí| E["monto_detraccion = ceil(monto)<br>Cuota 1: monto_detraccion<br>U_VS_TIPOCUOTA = D<br>U_VS_TIPOSERVICIO = codigo + '02'"]
|
||||||
|
E --> F["Cuota 2: neto = total - monto_detraccion<br>U_VS_TIPOCUOTA = N"]
|
||||||
|
D -->|No| G["Cuota única por el total<br>U_VS_TIPOCUOTA = N"]
|
||||||
|
|
||||||
|
F --> H["Por cada línea de factura (índice i)"]
|
||||||
|
G --> H
|
||||||
|
|
||||||
|
H --> I["guias_lt[] = resultados_val[i]['guias_lt']<br>(si no hay, se salta la línea)"]
|
||||||
|
I --> K["Por cada item en guias_lt[]"]
|
||||||
|
|
||||||
|
K --> L["PriceAfterVAT = draft_price<br>truncado a 6 decimales"]
|
||||||
|
L --> M["Quantity = draft_quantity"]
|
||||||
|
M --> N["Crear DocumentLine:<br>ItemCode, Quantity, PriceAfterVAT,<br>WarehouseCode, FreeText (descripción[:100]),<br>campos fijos U_* / Costing / TaxCode I18,<br>U_IDC_GRFLETE = tipo+serie-numero de la guía"]
|
||||||
|
N --> K
|
||||||
|
|
||||||
|
K --> P["U_IDC_VALREF = suma de valor_referencial<br>de las líneas del XML"]
|
||||||
|
P --> Q["U_IDC_NUMGR = primera guía de la<br>última línea que tuvo guías"]
|
||||||
|
Q --> R["Cabecera: DocType, DocDate = hoy,<br>DocDueDate, TaxDate = fecha_emision,<br>CardCode, NumAtCard, Comments,<br>U_VS_ANEXO_PDF / U_VS_ANEXO_XML<br>(rutas de red de copiar_a_red)"]
|
||||||
|
R --> S["DocumentLines[] + DocumentInstallments[]"]
|
||||||
|
S --> T["← Retornar dict para POST /Drafts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- `draft_quantity` y `draft_price` los fija `validate_prices`: con `ok=True` salen de SAP (`int(max Quantity FL*)` y suma de `PriceAfterVAT` FL*); con `ok=False` salen del XML (`int(cantidad)` y `total_linea / cantidad`). Ver `validar_precios.md`.
|
||||||
|
- El valor referencial del draft (`U_IDC_VALREF`) sale del XML SUNAT (`sunat_schema`), no de SAP. La comparación contra SAP (`Sp_ObtenerValorReferencial`, vía `hana_manager`) solo se muestra en el correo de notificación.
|
||||||
|
- El `DueDate` de las cuotas es el mismo `fecha_vencimiento` que usa la cabecera (`DocDueDate`): fecha de contabilización + días de la condición de pago. `TaxDate` es la única fecha que lleva la emisión del comprobante.
|
||||||
|
- Si `obtener_condicion_pago` no encuentra condición para el proveedor, `DocDueDate` = fecha de contabilización.
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Construir Draft SAP B1
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["factura + resultados_val"] --> B["Determinar ItemCode<br>C103002-A (mes anterior)<br>C103002 (mes actual)"]
|
|
||||||
B --> B2["CardCode = PL + ruc_emisor<br>sapb1_manager.obtener_condicion_pago(CardCode)<br>(BusinessPartners.PayTermsGrpCode → PaymentTermsTypes)"]
|
|
||||||
B2 --> B3["fecha_vencimiento = fecha_contabilizacion +<br>NumberOfAdditionalDays<br>(si es 0, usar NumberOfAdditionalMonths * 30)"]
|
|
||||||
B3 --> C["Calcular cuotas<br>DocumentInstallments"]
|
|
||||||
|
|
||||||
C --> D{"¿Tiene detracción?"}
|
|
||||||
D -->|Sí| E["Cuota 1: monto detracción<br>U_VS_TIPOCUOTA = D"]
|
|
||||||
E --> F["Cuota 2: neto = total - detracción<br>U_VS_TIPOCUOTA = N"]
|
|
||||||
D -->|No| G["Cuota única por el total<br>U_VS_TIPOCUOTA = N"]
|
|
||||||
|
|
||||||
F --> H["Por cada línea de factura"]
|
|
||||||
G --> H
|
|
||||||
|
|
||||||
H --> I["Obtener guias_lt[]<br>desde resultado_validacion"]
|
|
||||||
I --> J["zona_guia = guias_lt[0]['zona']"]
|
|
||||||
J --> K["Por cada item en guias_lt[]"]
|
|
||||||
|
|
||||||
K --> L["PriceAfterVAT:<br>suma pav FL* si use_sap_prices<br>line_total si no"]
|
|
||||||
L --> M["Qty = item['quantity']"]
|
|
||||||
M --> N["Crear DocumentLine:<br>ItemCode, Quantity,<br>PriceAfterVAT, WarehouseCode,<br>U_IDC_GRFLETE"]
|
|
||||||
|
|
||||||
N --> O["Acumular total_weight<br>+= item['weight1']"]
|
|
||||||
O --> K
|
|
||||||
|
|
||||||
K --> P["U_IDC_VALREF =<br>round((total_weight / 1000) *<br>CUADRO_VALOR_REFERENCIAL[zona], 2)"]
|
|
||||||
|
|
||||||
P --> Q["Armar payload completo"]
|
|
||||||
Q --> R["Cabecera:<br>DocType, CardCode, NumAtCard,<br>DocDate = fecha_contabilizacion,<br>DocDueDate = fecha_vencimiento,<br>U_IDC_NUMGR, Comments"]
|
|
||||||
R --> S["DocumentLines[] +<br>DocumentInstallments[]"]
|
|
||||||
S --> T["← Retornar dict para<br>POST /Drafts"]
|
|
||||||
```
|
|
||||||
@@ -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.1 # 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.1)`, 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.1
|
||||||
|
.\deploy\actualizar.ps1 -Version v1.2.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 |
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# hana_manager.py — SAP HANA directo
|
||||||
|
|
||||||
|
Acceso de solo lectura a SAP HANA con `hdbcli`. Se usa para dos cosas:
|
||||||
|
|
||||||
|
1. **Fallback de `sapb1_manager.get_delivery_notes`**: ubicar una guía de remisión cuando el Service Layer no la encuentra.
|
||||||
|
2. **Valor referencial por guía**: ejecutar `Sp_ObtenerValorReferencial` (lo consume `draft_sap.obtener_valor_referencial_sap`).
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
hana_manager.py
|
||||||
|
├── CONFIG (.env): HANA_SERVER, HANA_PORT (30015), HANA_USER, HANA_PASSWORD, HANA_SCHEMA
|
||||||
|
├── get_connection() → conexión | None
|
||||||
|
├── get_delivery(tipo, serie, numero) → list[dict]
|
||||||
|
│ ├── _query_delivery_table(conn, "ODLN", ...) (Entregas)
|
||||||
|
│ └── _query_delivery_table(conn, "OWTR", ...) (Traslados; solo si ODLN no devolvió nada)
|
||||||
|
└── obtener_valor_referencial(serie, numero) → float
|
||||||
|
```
|
||||||
|
|
||||||
|
## `get_delivery(tipo, serie, numero)`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[get_delivery] --> B{get_connection OK?}
|
||||||
|
B -- No --> Z["return []"]
|
||||||
|
B -- Sí --> C["Consultar ODLN"]
|
||||||
|
C --> D{¿Hay resultados?}
|
||||||
|
D -- Sí --> R["return resultados (Tabla = 'ODLN')"]
|
||||||
|
D -- No --> E["Consultar OWTR"]
|
||||||
|
E --> R2["return resultados (Tabla = 'OWTR') o []"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Filtro aplicado en ambas tablas:
|
||||||
|
|
||||||
|
| Campo SAP | Valor |
|
||||||
|
|-----------|-------|
|
||||||
|
| `CANCELED` | `'N'` |
|
||||||
|
| `U_BPP_MDTD` | `tipo` (código SUNAT: `09`, `31`) |
|
||||||
|
| `U_BPP_MDSD` | `serie` |
|
||||||
|
| `U_BPP_MDCD` | `LIKE '%numero%'` |
|
||||||
|
|
||||||
|
Cada registro devuelto trae `DocEntry`, `DocNum`, `CardCode`, `CardName` y `Tabla`. El campo `Tabla` le dice a `sapb1_manager` qué endpoint del Service Layer usar después: `DeliveryNotes` (ODLN) o `StockTransfers` (OWTR, que además requiere resolver la orden de venta por `U_IDC_NUMOV`).
|
||||||
|
|
||||||
|
## `obtener_valor_referencial(serie, numero)`
|
||||||
|
|
||||||
|
Ejecuta `CALL "<schema>"."Sp_ObtenerValorReferencial"(serie, numero)` y devuelve la columna `ValorReferencial` del primer registro. Retorna `0.0` si no hay filas, si el valor es nulo o si ocurre un error.
|
||||||
|
|
||||||
|
## Comportamiento ante errores
|
||||||
|
|
||||||
|
Ninguna función lanza excepciones: todas registran un `ERROR` en el log y devuelven un valor neutro (`None` en `get_connection`, `[]` en `get_delivery`, `0.0` en `obtener_valor_referencial`). Por eso las credenciales de HANA son obligatorias en `config_validator`: sin ellas el pipeline seguiría ejecutándose con valores referenciales en 0 sin detenerse.
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Cada llamada abre y cierra su propia conexión (no hay pool).
|
||||||
|
- El número se busca con `LIKE '%numero%'`: es una coincidencia parcial, así que un número corto podría coincidir con más de un documento de la misma serie.
|
||||||
|
- El esquema se interpola en el SQL desde `HANA_SCHEMA` (`.env`); los valores de búsqueda sí van como parámetros (`?`).
|
||||||
+74
-47
@@ -4,17 +4,33 @@
|
|||||||
|
|
||||||
| Archivo | Líneas | Rol |
|
| Archivo | Líneas | Rol |
|
||||||
|---------|--------|-----|
|
|---------|--------|-----|
|
||||||
| `main.py` | 134 | Demonio orquestador (loop de horarios) |
|
| `main.py` | 142 | Demonio orquestador (loop de horarios) |
|
||||||
| `pipeline.py` | 1284 | Pipeline core (procesar un correo completo) |
|
| `pipeline.py` | 437 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
|
||||||
| `sunat_schema.py` | 720 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
|
| `pipeline_utils.py` | 38 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
|
||||||
| `pdf_utilities.py` | 464 | Parseo de PDFs (factura, guías) |
|
| `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML |
|
||||||
| `sapb1_manager.py` | 321 | Comunicación con SAP B1 Service Layer |
|
| `documentos.py` | 378 | Extracción PDF/XML, asignación de guías, copia a red |
|
||||||
| `outlook_utilities.py` | 234 | Comunicación con Microsoft Graph |
|
| `persistencia.py` | 165 | Guardado de JSON de resultados y de drafts |
|
||||||
| `hana_manager.py` | 123 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) |
|
| `draft_sap.py` | 444 | Validación de precios contra SAP, valor referencial y construcción del draft |
|
||||||
| `database_utilities.py` | 116 | SQLite local (cache de correos) |
|
| `notificaciones.py` | 121 | Cuerpos HTML de los correos de notificación |
|
||||||
| `consulta_rapida.py` | 113 | Script auxiliar para reprocesar correos por ID |
|
| `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
|
||||||
| `email_utilities.py` | 52 | Utilitarios de adjuntos (ZIP, filtrado) |
|
| `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) |
|
||||||
| `logger_manager.py` | 149 | Logging a archivo rotado (30 días) + consola + captura de `print()` |
|
| `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` | 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` | 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: 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,8 +48,19 @@ main.py
|
|||||||
├── outlook_utilities.py
|
├── outlook_utilities.py
|
||||||
├── email_utilities.py
|
├── email_utilities.py
|
||||||
└── database_utilities.py
|
└── database_utilities.py
|
||||||
|
```
|
||||||
|
|
||||||
consulta_rapida.py
|
Módulos que `pipeline.py` orquesta (todos usan `pipeline_utils.py`):
|
||||||
|
|
||||||
|
```
|
||||||
|
pipeline.py
|
||||||
|
├── adjuntos.py → outlook_utilities
|
||||||
|
├── documentos.py → sunat_schema, pdf_utilities
|
||||||
|
├── persistencia.py → sunat_schema, pdf_utilities
|
||||||
|
├── draft_sap.py → sapb1_manager, hana_manager
|
||||||
|
└── notificaciones.py
|
||||||
|
|
||||||
|
tools/consulta_rapida.py
|
||||||
├── logger_manager.py
|
├── logger_manager.py
|
||||||
├── database_utilities.py
|
├── database_utilities.py
|
||||||
└── pipeline.py (_procesar_un_correo, step_authenticate)
|
└── pipeline.py (_procesar_un_correo, step_authenticate)
|
||||||
@@ -50,28 +77,28 @@ main()
|
|||||||
├── step_fetch_emails() → emails[]
|
├── step_fetch_emails() → emails[]
|
||||||
└── _procesar_un_correo() (por cada email)
|
└── _procesar_un_correo() (por cada email)
|
||||||
├── BD: verificar duplicado
|
├── BD: verificar duplicado
|
||||||
├── _descargar_adjuntos() → archivos
|
├── adjuntos.descargar_adjuntos() → archivos
|
||||||
├── _descomprimir() ZIPs
|
├── email_utilities.descomprimir_adjuntos() ZIPs
|
||||||
├── _filtrar_y_limpiar() → solo PDF/XML
|
├── adjuntos.filtrar_y_limpiar() → solo PDF/XML
|
||||||
├── _procesar_pdfs() → pdf_cache
|
├── documentos.procesar_pdfs() → pdf_cache
|
||||||
│ └── pdf_utilities.extraer_datos_pdf()
|
│ └── pdf_utilities.extraer_datos_pdf()
|
||||||
├── _procesar_xmls(pdf_cache) → documentos[]
|
├── documentos.procesar_xmls(pdf_cache) → documentos[]
|
||||||
│ ├── sunat_schema.extraer_datos_xml()
|
│ ├── sunat_schema.extraer_datos_xml()
|
||||||
│ └── _copiar_a_red() → copia PDF/XML a ruta de red (RUTA_RED_FACTURACION)
|
│ └── documentos.copiar_a_red() → copia PDF/XML a ruta de red (RUTA_RED_FACTURACION)
|
||||||
├── _completar_guias(documentos, pdf_cache) → completa guías faltantes desde PDF
|
├── documentos.completar_guias(documentos, pdf_cache) → completa guías faltantes desde PDF
|
||||||
├── _validar_guias(documentos) → advierte guías con datos incompletos
|
├── documentos.validar_guias(documentos) → advierte guías con datos incompletos
|
||||||
├── _guardar_resultado(documentos)
|
├── persistencia.guardar_resultado(documentos)
|
||||||
├── _validate_prices(factura) → (ok, resultados)
|
├── draft_sap.validate_prices(factura) → (ok, resultados)
|
||||||
│ └── sapb1_manager.get_delivery_notes()
|
│ └── sapb1_manager.get_delivery_notes()
|
||||||
│ ├── SL (Service Layer) GET /DeliveryNotes
|
│ ├── SL (Service Layer) GET /DeliveryNotes
|
||||||
│ ├── HANA fallback (get_delivery: ODLN, luego OWTR)
|
│ ├── HANA fallback (get_delivery: ODLN, luego OWTR)
|
||||||
│ └── si vino de OWTR: GET StockTransfers(DocEntry)
|
│ └── si vino de OWTR: GET StockTransfers(DocEntry)
|
||||||
│ → U_IDC_NUMOV → GET Orders(U_IDC_NUMOV)
|
│ → U_IDC_NUMOV → GET Orders(U_IDC_NUMOV)
|
||||||
├── _obtener_valor_referencial_sap(factura) → guias_valor_ref
|
├── draft_sap.obtener_valor_referencial_sap(factura) → guias_valor_ref
|
||||||
│ └── hana_manager.obtener_valor_referencial() (Sp_ObtenerValorReferencial)
|
│ └── hana_manager.obtener_valor_referencial() (Sp_ObtenerValorReferencial)
|
||||||
├── _build_sap_draft(factura, resultados) → draft (incluye U_VS_ANEXO_PDF/XML)
|
├── draft_sap.build_sap_draft(factura, resultados) → draft (incluye U_VS_ANEXO_PDF/XML)
|
||||||
│ └── sapb1_manager.obtener_condicion_pago(CardCode) → DocDueDate
|
│ └── sapb1_manager.obtener_condicion_pago(CardCode) → DocDueDate
|
||||||
├── _guardar_draft_json(draft)
|
├── persistencia.guardar_draft_json(draft)
|
||||||
├── sapb1_manager.create_draft(draft)
|
├── sapb1_manager.create_draft(draft)
|
||||||
└── outlook_utilities.send_email(notificación)
|
└── outlook_utilities.send_email(notificación)
|
||||||
```
|
```
|
||||||
@@ -109,27 +136,27 @@ main()
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Funciones internas en `_procesar_un_correo` (pipeline.py)
|
## Funciones por módulo
|
||||||
|
|
||||||
> Nota: `_ruta_carpeta` y `_copiar_a_red` son helpers a nivel de módulo (no anidados en `_procesar_un_correo`); el resto son funciones anidadas dentro de `_procesar_un_correo` (líneas 142-1304).
|
> Antes eran funciones anidadas en `_procesar_un_correo`; ahora son funciones de módulo que reciben explícitamente `email`, `download_folder`, `output_folder`, etc. `pipeline.py` solo las orquesta.
|
||||||
|
|
||||||
| Función | Líneas | Propósito |
|
| Módulo | Función | Propósito |
|
||||||
|---------|--------|-----------|
|
|--------|---------|-----------|
|
||||||
| `_ruta_carpeta` (módulo) | 33-34 | Resuelve una ruta relativa al directorio del script |
|
| `pipeline_utils` | `ruta_carpeta` | Resuelve una ruta relativa al directorio del proyecto |
|
||||||
| `_copiar_a_red` (módulo) | 37-94 | Copia PDF/XML a la ruta de red de Facturación Electrónica (`RUTA_RED_FACTURACION`) |
|
| `pipeline_utils` | `log` | Imprime con indentación de árbol (niveles 1-4) |
|
||||||
| `_descargar_adjuntos` | 169-196 | Descarga adjuntos vía Graph API |
|
| `adjuntos` | `descargar_adjuntos` | Descarga adjuntos vía Graph API |
|
||||||
| `_descomprimir` | 198-211 | Descomprime ZIPs |
|
| `adjuntos` | `filtrar_y_limpiar` | Filtra PDF/XML, elimina el resto |
|
||||||
| `_filtrar_y_limpiar` | 213-253 | Filtra PDF/XML, elimina el resto |
|
| `documentos` | `copiar_a_red` | Copia PDF/XML a la ruta de red de Facturación Electrónica (`RUTA_RED_FACTURACION`) |
|
||||||
| `_procesar_pdfs` | 255-308 | Extrae datos de PDFs, renombra, genera cache |
|
| `documentos` | `procesar_pdfs` | Extrae datos de PDFs, renombra, genera cache |
|
||||||
| `_procesar_xmls` | 310-463 | Procesa XMLs, cruza con PDF, asigna guías, copia a red |
|
| `documentos` | `procesar_xmls` | Procesa XMLs, cruza con PDF, asigna guías, copia a red |
|
||||||
| `_completar_guias` | 465-522 | Completa guías faltantes en líneas XML desde el PDF cache |
|
| `documentos` | `completar_guias` | Completa guías faltantes en líneas XML desde el PDF cache |
|
||||||
| `_validar_guias` | 524-532 | Advierte (log) guías con datos incompletos |
|
| `documentos` | `validar_guias` | Advierte (log) guías con datos incompletos |
|
||||||
| `_guardar_resultado` | 534-653 | Guarda JSON con datos extraídos (XML y PDF) |
|
| `persistencia` | `guardar_resultado` | Guarda JSON con datos extraídos (XML y PDF) |
|
||||||
| `_calcular_fecha_vencimiento` | 665-692 | Calcula `DocDueDate` = fecha de contabilización + días de la condición de pago (`PaymentTermsTypes`) |
|
| `persistencia` | `guardar_draft_json` | Guarda draft JSON para depuración |
|
||||||
| `_build_sap_draft` | 694-855 | Construye payload del draft SAP B1 |
|
| `draft_sap` | `calcular_fecha_vencimiento` | Calcula `DocDueDate` = fecha de contabilización + días de la condición de pago (`PaymentTermsTypes`) |
|
||||||
| `_validate_prices` | 856-1030 | Valida precios contra DeliveryNotes / Orders (SAP) |
|
| `draft_sap` | `build_sap_draft` | Construye payload del draft SAP B1 |
|
||||||
| `_build_validation_html` | 1032-1061 | Genera tabla HTML de validación de precios |
|
| `draft_sap` | `validate_prices` | Valida precios contra DeliveryNotes / Orders (SAP) |
|
||||||
| `_obtener_valor_referencial_sap` | 1062-1090 | Consulta `Sp_ObtenerValorReferencial` en HANA por guía |
|
| `draft_sap` | `obtener_valor_referencial_sap` | Consulta `Sp_ObtenerValorReferencial` en HANA por guía |
|
||||||
| `_build_valor_referencial_html` | 1091-1126 | Genera tabla HTML comparando valor referencial XML vs SAP |
|
| `notificaciones` | `build_validation_html` | Genera tabla HTML de validación de precios |
|
||||||
| `_build_notification_body` | 1127-1168 | Arma cuerpo del email de notificación |
|
| `notificaciones` | `build_valor_referencial_html` | Genera tabla HTML comparando valor referencial XML vs SAP |
|
||||||
| `_guardar_draft_json` | 1169-1195 | Guarda draft JSON para depuración |
|
| `notificaciones` | `build_notification_body` | Arma cuerpo del email de notificación |
|
||||||
+13
-6
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
main.py
|
main.py
|
||||||
├── Constantes: HORARIOS (5 bloques horarios)
|
├── Constantes: HORARIOS (7 bloques que cubren las 24 horas)
|
||||||
├── obtener_intervalo(dt) → (intervalo, modo) | None
|
├── obtener_intervalo(dt) → (intervalo, modo) | None
|
||||||
├── segundos_hasta_proximo_inicio(dt) → int
|
├── segundos_hasta_proximo_inicio(dt) → int
|
||||||
├── non_negative_int(value) → int (validador argparse)
|
├── non_negative_int(value) → int (validador argparse)
|
||||||
@@ -69,7 +69,7 @@ main()
|
|||||||
│ │
|
│ │
|
||||||
│ └── ¿No está en horario?
|
│ └── ¿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..."
|
│ └── Log "Fuera del horario, próxima ejecución en..."
|
||||||
│ │
|
│ │
|
||||||
│ └── 3.3. time.sleep(dormir) → vuelve a 3.1
|
│ └── 3.3. time.sleep(dormir) → vuelve a 3.1
|
||||||
@@ -81,13 +81,15 @@ main()
|
|||||||
|
|
||||||
| Desde | Hasta | Intervalo | Modo |
|
| Desde | Hasta | Intervalo | Modo |
|
||||||
|---------|---------|-----------|----------|
|
|---------|---------|-----------|----------|
|
||||||
|
| 00:00 | 05:00 | 60s | rápido |
|
||||||
|
| 05:00 | 06:30 | 1800s | full |
|
||||||
| 06:30 | 13:15 | 300s | rápido |
|
| 06:30 | 13:15 | 300s | rápido |
|
||||||
| 13:15 | 14:00 | 1800s | full |
|
| 13:15 | 14:00 | 1800s | full |
|
||||||
| 14:00 | 18:30 | 300s | rápido |
|
| 14:00 | 18:30 | 300s | rápido |
|
||||||
| 18:30 | 21:30 | 3600s | full |
|
| 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)
|
## Entry point (línea 126-134)
|
||||||
|
|
||||||
@@ -96,7 +98,12 @@ if __name__ == "__main__":
|
|||||||
1. logger_manager.setup_logging()
|
1. logger_manager.setup_logging()
|
||||||
└── Configura logging a archivo rotado (logs/descarga_factura_transporte.log,
|
└── Configura logging a archivo rotado (logs/descarga_factura_transporte.log,
|
||||||
rotación diaria, 30 días de historial) + consola, y redirige print()/stderr
|
rotación diaria, 30 días de historial) + consola, y redirige print()/stderr
|
||||||
al mismo log (no hace falta migrar cada print a logger.info).
|
al mismo log (los módulos del pipeline ya usan logger con nivel explícito;
|
||||||
2. print(...) banner de inicio, incluyendo logger_manager.LOG_FILE
|
los print() restantes se registran como INFO).
|
||||||
|
2. logger.info(...) banner de inicio, incluyendo logger_manager.LOG_FILE
|
||||||
3. main()
|
3. main()
|
||||||
|
└── Tras load_dotenv(), config_validator.verificar_configuracion():
|
||||||
|
termina con código 1 si falta alguna variable obligatoria del .env
|
||||||
|
(ver config_validator.OBLIGATORIAS) y advierte si la verificación SSL
|
||||||
|
está desactivada o no hay ruta de red.
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# 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). Cómo obtener las credenciales: [configurar_msal.md](configurar_msal.md).
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
outlook_utilities.py
|
||||||
|
├── CONFIG (.env): CLIENT_ID, CLIENT_SECRET, TENANT_ID, VERIFY_SSL (false por defecto)
|
||||||
|
├── get_access_token() → str
|
||||||
|
├── get_emails(user_email, token, fecha_inicio, fecha_fin) → list[dict]
|
||||||
|
├── download_attachments(user_email, message_id, token, download_path) → list[str]
|
||||||
|
└── send_email(access_token, from_email, to_recipients, subject, html_body,
|
||||||
|
attachment_paths=None, cc_recipients=None) → bool
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funciones
|
||||||
|
|
||||||
|
| 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` 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, 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
|
||||||
|
|
||||||
|
`get_emails` asume UTC-5 (Perú, sin horario de verano): el rango `fecha_inicio`–`fecha_fin` se convierte a UTC sumando 5 h (el fin incluye todo el día), y el campo `fecha` devuelto resta 5 h a `receivedDateTime`.
|
||||||
|
|
||||||
|
## Adjuntos de `send_email`
|
||||||
|
|
||||||
|
Los adjuntos se leen de las rutas indicadas. Una ruta inexistente se omite con un `WARNING`. El `contentType` se deduce de la extensión (`.pdf`, `.xml`, `.json`; cualquier otra, `application/octet-stream`).
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- `download_attachments` solo guarda adjuntos tipo `fileAttachment` con contenido; los adjuntos de tipo mensaje o referencia se ignoran.
|
||||||
|
- Dos correos con un adjunto del mismo nombre se sobrescriben entre sí si comparten `download_path` (el pipeline usa una única carpeta, `DOWNLOAD_FOLDER`).
|
||||||
|
- `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.
|
||||||
|
|
||||||
+100
-63
@@ -4,12 +4,21 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
pipeline.py
|
pipeline.py
|
||||||
├── CONFIG: RUTA_RED_FACTURACION, RUC_EMPRESA (desde .env)
|
|
||||||
├── HELPERS: _ruta_carpeta(), _copiar_a_red(data, _log)
|
|
||||||
├── PASO 1: step_authenticate() → token | None
|
├── PASO 1: step_authenticate() → token | None
|
||||||
├── PASO 2: step_fetch_emails() → list[dict]
|
├── PASO 2: step_fetch_emails() → list[dict]
|
||||||
├── _procesar_un_correo() → None (core, funciones anidadas)
|
├── _procesar_un_correo() → None (orquesta los módulos de abajo)
|
||||||
└── run_pipeline() → None
|
└── run_pipeline() → None
|
||||||
|
|
||||||
|
Módulos que orquesta _procesar_un_correo():
|
||||||
|
├── adjuntos.py descargar_adjuntos, filtrar_y_limpiar
|
||||||
|
├── 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 (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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,71 +51,80 @@ flowchart TD
|
|||||||
A[_procesar_un_correo] --> B[1-2. BD: ¿ya procesado?]
|
A[_procesar_un_correo] --> B[1-2. BD: ¿ya procesado?]
|
||||||
B -- Sí --> C[⏭️ Saltar]
|
B -- Sí --> C[⏭️ Saltar]
|
||||||
B -- No --> D[Guardar en BD]
|
B -- No --> D[Guardar en BD]
|
||||||
D --> E[3. _descargar_adjuntos]
|
D --> E[3. descargar_adjuntos]
|
||||||
E --> F{Hay adjuntos?}
|
E --> F{Hay adjuntos?}
|
||||||
F -- No --> C
|
F -- No --> C
|
||||||
F -- Sí --> G[4. _descomprimir ZIPs]
|
F -- Sí --> G[4. _descomprimir ZIPs]
|
||||||
G --> H[5. _filtrar_y_limpiar]
|
G --> H[5. filtrar_y_limpiar]
|
||||||
H --> I{Hay PDF/XML?}
|
H --> I{Hay PDF/XML?}
|
||||||
I -- No --> C
|
I -- No --> RC[Rechazo del correo<br>responder al remitente]
|
||||||
I -- Sí --> J[6. _procesar_pdfs]:::highlight
|
I -- Sí --> J[6. procesar_pdfs]:::highlight
|
||||||
J --> K[7. _procesar_xmls]:::highlight
|
J --> K[7. procesar_xmls]:::highlight
|
||||||
K --> K2[8. _completar_guias]
|
K --> K2[8. completar_guias]
|
||||||
K2 --> K3[9. _validar_guias]
|
K2 --> K3[9. validar_guias]
|
||||||
K3 --> L{Hay documentos?}
|
K3 --> L{Hay documentos?}
|
||||||
L -- No --> C
|
L -- No --> RC
|
||||||
L -- Sí --> M[10. _guardar_resultado]
|
L -- Sí --> M[10. guardar_resultado]
|
||||||
M --> N[11. Filtrar facturas tipo 01]
|
M --> N[11. Filtrar facturas tipo 01]
|
||||||
N --> O{Hay facturas?}
|
N --> O{Hay facturas?}
|
||||||
O -- No --> C
|
O -- No --> RC
|
||||||
O -- Sí --> P[Bucle: por cada factura]
|
O -- Sí --> P[Bucle: por cada factura]
|
||||||
|
|
||||||
P --> Q[12. _validate_prices]:::highlight
|
P --> P2{11.5 ¿Ya existe en SAP?}
|
||||||
Q --> Q2[12.5 _obtener_valor_referencial_sap]
|
P2 -- Sí --> V
|
||||||
Q2 --> R[13. _build_sap_draft]:::highlight
|
P2 -- No --> Q[12. validate_prices]:::highlight
|
||||||
R --> S[14. _guardar_draft_json]
|
Q --> Q2[12.5 obtener_valor_referencial_sap]
|
||||||
S --> T{Todas coinciden?}
|
Q2 --> R[13. build_sap_draft]:::highlight
|
||||||
T -- Sí --> U[15. sapb1_manager.create_draft]
|
R --> S[14. guardar_draft_json]
|
||||||
T -- No --> V[⏭️ Draft NO creado]
|
S --> T{¿SAP/HANA caídos<br>u otro error?}
|
||||||
U --> W[16. outlook_utilities.send_email]
|
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
|
V --> W
|
||||||
|
X --> W2[16. Correo técnico al equipo<br>NO se responde al proveedor]
|
||||||
|
FL --> P
|
||||||
W --> P
|
W --> P
|
||||||
|
W2 --> P
|
||||||
|
|
||||||
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
classDef highlight fill:#fff3e0,stroke:#e65100,stroke-width:3px;
|
||||||
```
|
```
|
||||||
|
|
||||||
`_procesar_xmls` (paso 7) incluye internamente la copia de PDF/XML a la ruta de red (`_copiar_a_red`, ver más abajo) por cada comprobante procesado.
|
`procesar_xmls` (paso 7) incluye internamente la copia de PDF/XML a la ruta de red (`copiar_a_red`, ver más abajo) por cada comprobante procesado.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Funciones internas de `_procesar_un_correo`
|
## Funciones por módulo
|
||||||
|
|
||||||
> `_ruta_carpeta` y `_copiar_a_red` están definidas a nivel de módulo (no anidadas); el resto son funciones anidadas dentro de `_procesar_un_correo`.
|
> Son funciones de módulo (antes anidadas en `_procesar_un_correo`) y reciben explícitamente `email`, `download_folder`, `output_folder`, etc. `_procesar_un_correo` solo las orquesta.
|
||||||
|
|
||||||
| Función | Línea | Propósito |
|
| Función | Módulo | Propósito |
|
||||||
|---------|-------|-----------|
|
|---------|--------|-----------|
|
||||||
| `_ruta_carpeta()` (módulo) | 33 | Resuelve una ruta relativa al directorio del script |
|
| `ruta_carpeta()` | `pipeline_utils` | Resuelve una ruta relativa al directorio del proyecto |
|
||||||
| `_copiar_a_red(data, _log)` (módulo) | 37 | Copia PDF/XML de un comprobante a la ruta de red configurable |
|
| `log(msg, level, last)` | `pipeline_utils` | Imprime con indentación de árbol |
|
||||||
| `_descargar_adjuntos()` | 169 | Descarga adjuntos vía Microsoft Graph |
|
| `copiar_a_red(data)` | `documentos` | Copia PDF/XML de un comprobante a la ruta de red configurable |
|
||||||
| `_descomprimir()` | 198 | Descomprime archivos ZIP |
|
| `descargar_adjuntos(email, email_addr, token, download_folder)` | `adjuntos` | Descarga adjuntos vía Microsoft Graph |
|
||||||
| `_filtrar_y_limpiar()` | 213 | Filtra solo PDF/XML, elimina el resto |
|
| `filtrar_y_limpiar(email, download_folder)` | `adjuntos` | Filtra solo PDF/XML, elimina el resto |
|
||||||
| **`_procesar_pdfs()`** | **255** | **Extrae datos de PDFs, renombra, genera cache** |
|
| **`procesar_pdfs(email, download_folder)`** | `documentos` | **Extrae datos de PDFs, renombra, genera cache** |
|
||||||
| **`_procesar_xmls(pdf_cache)`** | **310** | **Procesa XMLs, cruza con PDF, asigna guías, copia a red** |
|
| **`procesar_xmls(email, download_folder, pdf_cache)`** | `documentos` | **Procesa XMLs, cruza con PDF, asigna guías, copia a red** |
|
||||||
| `_completar_guias(documentos, pdf_cache)` | 465 | Completa guías faltantes en líneas XML desde el PDF cache |
|
| `completar_guias(documentos, pdf_cache)` | `documentos` | Completa guías faltantes en líneas XML desde el PDF cache |
|
||||||
| `_validar_guias(documentos)` | 524 | Advierte (log) guías con datos incompletos (falta tipo/serie/numero) |
|
| `validar_guias(documentos)` | `documentos` | Advierte (log) guías con datos incompletos (falta tipo/serie/numero) |
|
||||||
| `_guardar_resultado(documentos, datos_pdf)` | 534 | Guarda JSON con datos extraídos (XML y PDF) |
|
| `guardar_resultado(documentos, datos_pdf, output_folder)` | `persistencia` | Guarda JSON con datos extraídos (XML y PDF) |
|
||||||
| `_calcular_fecha_vencimiento(fecha_base, condicion_pago)` | 665 | Calcula `DocDueDate` = fecha de contabilización + días de `PaymentTermsTypes` |
|
| `guardar_draft_json(draft, serie, numero, output_folder)` | `persistencia` | Guarda draft JSON para depuración |
|
||||||
| **`_build_sap_draft(factura, resultados_val)`** | **694** | **Construye payload del draft SAP B1 (incluye `U_VS_ANEXO_PDF`/`U_VS_ANEXO_XML`, `DocDueDate` por condición de pago)** |
|
| `calcular_fecha_vencimiento(fecha_base, condicion_pago)` | `draft_sap` | Calcula `DocDueDate` = fecha de contabilización + días de `PaymentTermsTypes` |
|
||||||
| **`_validate_prices(factura)`** | **856** | **Valida precios contra DeliveryNotes/Orders SAP** |
|
| **`build_sap_draft(factura, resultados_val)`** | `draft_sap` | **Construye payload del draft SAP B1 (incluye `U_VS_ANEXO_PDF`/`U_VS_ANEXO_XML`, `DocDueDate` por condición de pago)** |
|
||||||
| `_build_validation_html(resultados)` | 1032 | Genera tabla HTML de validación de precios |
|
| **`validate_prices(factura)`** | `draft_sap` | **Valida precios contra DeliveryNotes/Orders SAP** |
|
||||||
| `_obtener_valor_referencial_sap(factura)` | 1062 | Consulta `Sp_ObtenerValorReferencial` en HANA por cada guía única |
|
| `obtener_valor_referencial_sap(factura)` | `draft_sap` | Consulta `Sp_ObtenerValorReferencial` en HANA por cada guía única |
|
||||||
| `_build_valor_referencial_html(factura, guias_valor_ref)` | 1091 | Genera tabla HTML comparando valor referencial XML vs SAP |
|
| `build_validation_html(resultados)` | `notificaciones` | Genera tabla HTML de validación de precios |
|
||||||
| `_build_notification_body(status, factura, validation_table, valor_referencial_table, **kwargs)` | 1127 | Arma cuerpo del email de notificación |
|
| `build_valor_referencial_html(factura, guias_valor_ref)` | `notificaciones` | Genera tabla HTML comparando valor referencial XML vs SAP |
|
||||||
| `_guardar_draft_json(draft, serie, numero)` | 1169 | Guarda draft JSON para depuración |
|
| `build_notification_body(status, factura, validation_table, valor_referencial_table, **kwargs)` | `notificaciones` | Arma cuerpo del email de notificación |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## `_copiar_a_red(data, _log)` — Copia a ruta de red
|
## `copiar_a_red(data)` — Copia a ruta de red
|
||||||
|
|
||||||
Copia el PDF y el XML de cada comprobante procesado a una ruta de red UNC configurable, en paralelo al guardado local existente (no lo reemplaza). No falla el pipeline si la ruta no está configurada o no está disponible: solo registra una advertencia.
|
Copia el PDF y el XML de cada comprobante procesado a una ruta de red UNC configurable, en paralelo al guardado local existente (no lo reemplaza). No falla el pipeline si la ruta no está configurada o no está disponible: solo registra una advertencia.
|
||||||
|
|
||||||
@@ -118,8 +136,8 @@ Copia el PDF y el XML de cada comprobante procesado a una ruta de red UNC config
|
|||||||
{RUC_EMPRESA}-{tipo}-{serie}-{numero}.xml
|
{RUC_EMPRESA}-{tipo}-{serie}-{numero}.xml
|
||||||
```
|
```
|
||||||
`RUC_EMPRESA` es el RUC de Aberio (fijo); `ruc_emisor` es el RUC del proveedor que emitió la factura (varía por comprobante). Año/mes/día salen de `fecha_emision`, sin ceros a la izquierda en mes/día.
|
`RUC_EMPRESA` es el RUC de Aberio (fijo); `ruc_emisor` es el RUC del proveedor que emitió la factura (varía por comprobante). Año/mes/día salen de `fecha_emision`, sin ceros a la izquierda en mes/día.
|
||||||
- Guarda las rutas resultantes en `data["ruta_red_pdf"]` / `data["ruta_red_xml"]`, que luego usa `_build_sap_draft` para llenar `U_VS_ANEXO_PDF` / `U_VS_ANEXO_XML` en el draft SAP B1.
|
- Guarda las rutas resultantes en `data["ruta_red_pdf"]` / `data["ruta_red_xml"]`, que luego usa `build_sap_draft` para llenar `U_VS_ANEXO_PDF` / `U_VS_ANEXO_XML` en el draft SAP B1.
|
||||||
- Se invoca desde `_procesar_xmls`, justo después de resolver `data["ruta_pdf"]`.
|
- Se invoca desde `procesar_xmls`, justo después de resolver `data["ruta_pdf"]`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -134,23 +152,23 @@ run_pipeline
|
|||||||
│ └── outlook_utilities.get_emails
|
│ └── outlook_utilities.get_emails
|
||||||
└── _procesar_un_correo (por cada email)
|
└── _procesar_un_correo (por cada email)
|
||||||
├── database_utilities.get_existing_ids / save_emails
|
├── database_utilities.get_existing_ids / save_emails
|
||||||
├── _descargar_adjuntos
|
├── descargar_adjuntos
|
||||||
│ └── outlook_utilities.download_attachments
|
│ └── outlook_utilities.download_attachments
|
||||||
├── _descomprimir
|
├── _descomprimir
|
||||||
│ └── email_utilities.descomprimir_adjuntos
|
│ └── email_utilities.descomprimir_adjuntos
|
||||||
├── _filtrar_y_limpiar
|
├── filtrar_y_limpiar
|
||||||
├── _procesar_pdfs
|
├── procesar_pdfs
|
||||||
│ └── pdf_utilities.extraer_datos_pdf / renombrar_pdf
|
│ └── pdf_utilities.extraer_datos_pdf / renombrar_pdf
|
||||||
├── _procesar_xmls
|
├── procesar_xmls
|
||||||
│ ├── sunat_schema.extraer_datos_xml
|
│ ├── sunat_schema.extraer_datos_xml
|
||||||
│ ├── pdf_utilities.extraer_referencias
|
│ ├── pdf_utilities.extraer_referencias
|
||||||
│ └── _copiar_a_red
|
│ └── copiar_a_red
|
||||||
├── _completar_guias
|
├── completar_guias
|
||||||
├── _validar_guias
|
├── validar_guias
|
||||||
├── _guardar_resultado
|
├── guardar_resultado
|
||||||
│ └── sunat_schema.split_serie_numero, pdf_utilities.TIPO_MAP
|
│ └── sunat_schema.split_serie_numero, pdf_utilities.TIPO_MAP
|
||||||
├── (por cada factura tipo 01)
|
├── (por cada factura tipo 01)
|
||||||
│ ├── _validate_prices
|
│ ├── validate_prices
|
||||||
│ │ ├── sapb1_manager.get_zonas
|
│ │ ├── sapb1_manager.get_zonas
|
||||||
│ │ └── sapb1_manager.get_delivery_notes
|
│ │ └── sapb1_manager.get_delivery_notes
|
||||||
│ │ ├── hana_manager.get_delivery (fallback si SL no responde;
|
│ │ ├── hana_manager.get_delivery (fallback si SL no responde;
|
||||||
@@ -158,15 +176,34 @@ run_pipeline
|
|||||||
│ │ └── si el DocEntry vino de OWTR (StockTransfers), resuelve
|
│ │ └── si el DocEntry vino de OWTR (StockTransfers), resuelve
|
||||||
│ │ además la Orden de Venta asociada (Orders vía
|
│ │ además la Orden de Venta asociada (Orders vía
|
||||||
│ │ U_IDC_NUMOV) para poder validar las líneas FL*
|
│ │ U_IDC_NUMOV) para poder validar las líneas FL*
|
||||||
│ ├── _build_validation_html
|
│ ├── build_validation_html
|
||||||
│ ├── _obtener_valor_referencial_sap
|
│ ├── obtener_valor_referencial_sap
|
||||||
│ │ └── hana_manager.obtener_valor_referencial
|
│ │ └── hana_manager.obtener_valor_referencial
|
||||||
│ ├── _build_valor_referencial_html
|
│ ├── build_valor_referencial_html
|
||||||
│ ├── _build_sap_draft
|
│ ├── build_sap_draft
|
||||||
│ │ └── sapb1_manager.obtener_condicion_pago (BusinessPartners +
|
│ │ └── sapb1_manager.obtener_condicion_pago (BusinessPartners +
|
||||||
│ │ PaymentTermsTypes, para calcular DocDueDate)
|
│ │ PaymentTermsTypes, para calcular DocDueDate)
|
||||||
│ ├── _guardar_draft_json
|
│ ├── guardar_draft_json
|
||||||
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
|
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
|
||||||
│ ├── _build_notification_body
|
│ ├── build_notification_body
|
||||||
│ └── outlook_utilities.send_email
|
│ ├── respuesta_proveedor.enviar_a_equipo (correo técnico, solo equipo)
|
||||||
|
│ └── respuesta_proveedor.responder (plantilla aceptado/rechazado, ver respuesta_proveedor.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Qué se envía y a quién (paso 16)
|
||||||
|
|
||||||
|
| Resultado de la factura | Correo técnico (tablas de SAP, JSON adjuntos) | Respuesta con plantilla |
|
||||||
|
|-------------------------|-----------------------------------------------|-------------------------|
|
||||||
|
| `aceptado` (draft creado) | Equipo | Aceptado, al remitente |
|
||||||
|
| `rechazado` (precio, guía, 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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# seguimiento.py — Códigos de seguimiento y resultado por comprobante
|
||||||
|
|
||||||
|
Cada comprobante que llega por correo recibe un **código de seguimiento** (`ABR-AAMMDD-NNNNN`, por ejemplo `ABR-261019-00042`) que se le comunicará al proveedor y que permite reconocer el comprobante si lo reenvía. El registro también guarda el resultado del proceso y el estado de la notificación.
|
||||||
|
|
||||||
|
Los datos viven en la tabla `comprobantes` de `emails.db`. El esquema lo crea `database_utilities.init_db()` con `CREATE TABLE IF NOT EXISTS`, por lo que una base existente se actualiza sola sin tocar la tabla `emails`.
|
||||||
|
|
||||||
|
## Identidad
|
||||||
|
|
||||||
|
- Un comprobante se identifica por **(RUC emisor, tipo, serie, número)**. Si vuelve a llegar, en el mismo correo o en otro, conserva su código.
|
||||||
|
- Un correo que no trae ningún comprobante identificable (sin adjuntos, sin XML válido…) se registra aparte con `registrar_correo`: una fila por correo, con RUC, serie y número en `NULL`.
|
||||||
|
- El código sale del `id` de la fila (`ABR-` + fecha + `id` con 5 dígitos), así que es único por construcción, sin reintentos ni contadores aparte.
|
||||||
|
|
||||||
|
## Tabla `comprobantes`
|
||||||
|
|
||||||
|
| Columna | Contenido |
|
||||||
|
|---------|-----------|
|
||||||
|
| `codigo_seguimiento` | Código único del comprobante |
|
||||||
|
| `email_id` | Último correo en el que llegó |
|
||||||
|
| `ruc_emisor`, `tipo`, `serie`, `numero` | Identidad del comprobante (`NULL` en las filas de correo sin comprobante) |
|
||||||
|
| `estado` | `pendiente`, `aceptado`, `rechazado` o `error` |
|
||||||
|
| `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`, `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
|
||||||
|
|
||||||
|
| Función | Qué hace |
|
||||||
|
|---------|----------|
|
||||||
|
| `registrar_comprobante(email_id, ruc_emisor, tipo, serie, numero)` | Crea el registro o recupera el existente. Devuelve la fila más `reenvio` (True si ya había llegado en **otro** correo). Reprocesar el mismo correo no cambia nada. |
|
||||||
|
| `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, 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
|
||||||
|
|
||||||
|
En `pipeline._procesar_un_correo`, cada factura se registra al empezar (`_registrar_comprobante`) y su resultado se guarda al terminar (`_registrar_resultado`):
|
||||||
|
|
||||||
|
| Resultado del proceso | `estado` | Detalle guardado |
|
||||||
|
|-----------------------|----------|------------------|
|
||||||
|
| Draft creado en SAP | `aceptado` | `doc_entry` |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
## Observaciones guardadas
|
||||||
|
|
||||||
|
- 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` **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`.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Validar Precios
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["factura con lineas[]"] --> B["Obtener maestro zonas<br>SAP U_IDC_ZONAS"]
|
||||||
|
B --> C["Por cada línea de factura"]
|
||||||
|
|
||||||
|
C --> D{"cantidad > 0?"}
|
||||||
|
D -->|No| E["Resultado: cantidad cero, sin validar"]
|
||||||
|
D -->|Sí| F{"¿Hay guías de remisión?"}
|
||||||
|
F -->|No| G["Resultado: sin guía de referencia"]
|
||||||
|
|
||||||
|
F -->|Sí| H["Por cada guía en guias[]"]
|
||||||
|
H --> I["Consultar SAP B1<br>get_delivery_notes()"]
|
||||||
|
I --> I2{"¿Guía encontrada en<br>ODLN o en OWTR?"}
|
||||||
|
I2 -->|ODLN| I3["Documento = DeliveryNotes(DocEntry)"]
|
||||||
|
I2 -->|OWTR| I4["Documento = StockTransfers(DocEntry)<br>→ leer U_IDC_NUMOV<br>→ Documento = Orders(U_IDC_NUMOV)"]
|
||||||
|
I3 --> J["Por cada documento<br>(DeliveryNote u Orden de Venta)"]
|
||||||
|
I4 --> J
|
||||||
|
|
||||||
|
J --> K["Obtener zona desde<br>U_IDC_DESTINO"]
|
||||||
|
K --> L["Extraer WarehouseCode<br>de DocumentLines[0]"]
|
||||||
|
L --> M["Acumular Weight1<br>de DocumentLines[0]"]
|
||||||
|
M --> N["Por cada línea FL* en<br>DocumentLines"]
|
||||||
|
|
||||||
|
|
||||||
|
N --> O["Sumar LineTotal"]
|
||||||
|
O --> P["Máx Quantity"]
|
||||||
|
P --> Q["Sumar PriceAfterVAT"]
|
||||||
|
Q --> R["Construir guias_lt[]<br>para cada guía:<br>line_total, quantity,<br>price_after_vat_sum,<br>warehouse_code, zona, weight1"]
|
||||||
|
|
||||||
|
R --> S["Comparar total_linea (XML)<br>vs sap_total (FL*)"]
|
||||||
|
S --> T{"importes_coinciden:<br>diferencia redondeada a 2 decimales ≤ 0.01<br>y ninguna guía falló?"}
|
||||||
|
T -->|Sí| U["ok = True<br>draft_quantity = int(Quantity máx FL*)<br>draft_price = suma PriceAfterVAT FL*"]
|
||||||
|
T -->|No| V["ok = False<br>draft_quantity = int(cantidad XML)<br>draft_price = total_linea / cantidad"]
|
||||||
|
|
||||||
|
U --> W["Resultado por línea:<br>ok, draft_price, sap_price,<br>label, warehouse_code, guias_lt[]"]
|
||||||
|
V --> W
|
||||||
|
W --> C
|
||||||
|
C --> X["todas_coinciden = all(ok)"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Si la consulta a SAP de una guía lanza error, esa guía queda con ceros y la línea completa se marca `ok = False` (se registra como `WARNING`).
|
||||||
|
- Si ninguna guía de la línea existe en SAP, el resultado lleva `label = "Sin guías SAP"` y se registra "línea saltada", pero igual queda `ok = False` (salvo que `total_linea` sea 0), por lo que `todas_coinciden` es falso y no se crea el draft.
|
||||||
|
- `weight1` (peso de la primera línea de cada guía) se sigue calculando y guardando en `guias_lt[]`, pero el draft ya no lo usa.
|
||||||
|
- Función: `draft_sap.validate_prices(factura)` → `(todas_coinciden, resultados)`.
|
||||||
|
- **Tolerancia:** `pipeline_utils.importes_coinciden` redondea la diferencia entre el importe de la línea del XML y el de SAP a 2 decimales (lo que muestra un comprobante) y admite hasta 1 céntimo. Comparar la diferencia sin redondear (`abs(a - b) < 0.01`) no era fiable por la coma flotante: 431.41 − 431.40 daba `0.01000000000004775` (se rechazaba) y 624.39 − 624.38 daba `0.00999999999999091` (se aceptaba), así que el mismo céntimo pasaba o no según el importe. Con datos reales de SAP, ninguna línea cambió de resultado al introducir el redondeo. Se usa también para comparar el valor referencial del correo técnico.
|
||||||
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Validar Precios
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A["factura con lineas[]"] --> B["Obtener maestro zonas<br>SAP U_IDC_ZONAS"]
|
|
||||||
B --> C["Por cada línea de factura"]
|
|
||||||
|
|
||||||
C --> D{"cantidad > 0?"}
|
|
||||||
D -->|No| E["Resultado: cantidad cero, sin validar"]
|
|
||||||
D -->|Sí| F{"¿Hay guías de remisión?"}
|
|
||||||
F -->|No| G["Resultado: sin guía de referencia"]
|
|
||||||
|
|
||||||
F -->|Sí| H["Por cada guía en guias[]"]
|
|
||||||
H --> I["Consultar SAP B1<br>get_delivery_notes()"]
|
|
||||||
I --> I2{"¿Guía encontrada en<br>ODLN o en OWTR?"}
|
|
||||||
I2 -->|ODLN| I3["Documento = DeliveryNotes(DocEntry)"]
|
|
||||||
I2 -->|OWTR| I4["Documento = StockTransfers(DocEntry)<br>→ leer U_IDC_NUMOV<br>→ Documento = Orders(U_IDC_NUMOV)"]
|
|
||||||
I3 --> J["Por cada documento<br>(DeliveryNote u Orden de Venta)"]
|
|
||||||
I4 --> J
|
|
||||||
|
|
||||||
J --> K["Obtener zona desde<br>U_IDC_DESTINO"]
|
|
||||||
K --> L["Extraer WarehouseCode<br>de DocumentLines[0]"]
|
|
||||||
L --> M["Acumular Weight1<br>de DocumentLines[0]"]
|
|
||||||
M --> N["Por cada línea FL* en<br>DocumentLines"]
|
|
||||||
|
|
||||||
|
|
||||||
N --> O["Sumar LineTotal"]
|
|
||||||
O --> P["Máx Quantity"]
|
|
||||||
P --> Q["Sumar PriceAfterVAT"]
|
|
||||||
Q --> R["Construir guias_lt[]<br>para cada guía:<br>line_total, quantity,<br>price_after_vat_sum,<br>warehouse_code, zona, weight1"]
|
|
||||||
|
|
||||||
R --> S["Comparar total_linea (XML)<br>vs sap_total (FL*)"]
|
|
||||||
S --> T{"abs(diff) < 0.01?"}
|
|
||||||
T -->|Sí| U["ok = True<br>use_sap_prices si qty==1<br>y total_sap_qty≠1"]
|
|
||||||
T -->|No| V["ok = False"]
|
|
||||||
|
|
||||||
U --> W["Resultado por línea:<br>ok, draft_price, sap_price,<br>draft_cantidad, guias_lt[]"]
|
|
||||||
V --> W
|
|
||||||
W --> C
|
|
||||||
C --> X["todas_coinciden = all(ok)"]
|
|
||||||
```
|
|
||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
"""Extracción de datos de PDF/XML y asignación de guías de remisión."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import ntpath
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import sunat_schema
|
||||||
|
import pdf_utilities
|
||||||
|
from pipeline_utils import log, ruta_carpeta
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def copiar_a_red(data: dict) -> None:
|
||||||
|
"""
|
||||||
|
Copia el PDF y el XML de un comprobante ya procesado a la ruta de red
|
||||||
|
de Facturación Electrónica, organizada como::
|
||||||
|
|
||||||
|
{RUTA_RED_FACTURACION}\\{año}\\{mes}\\{día}\\{tipo}\\{RUC_EMPRESA}\\{ruc_emisor}\\
|
||||||
|
{RUC_EMPRESA}-{tipo}-{serie}-{numero}_FormatoImpreso.pdf
|
||||||
|
{RUC_EMPRESA}-{tipo}-{serie}-{numero}.xml
|
||||||
|
|
||||||
|
``RUC_EMPRESA`` es el RUC de Aberio (fijo, por ahora); ``ruc_emisor``
|
||||||
|
es el RUC del proveedor que emitió la factura (varía por comprobante).
|
||||||
|
Año/mes/día se toman de ``fecha_emision`` (mes y día sin ceros a la
|
||||||
|
izquierda). No falla el pipeline si la ruta de red no está configurada
|
||||||
|
o no está disponible: solo registra una advertencia.
|
||||||
|
|
||||||
|
Guarda las rutas resultantes en ``data["ruta_red_pdf"]`` y
|
||||||
|
``data["ruta_red_xml"]`` para que ``build_sap_draft`` las incluya en
|
||||||
|
el draft (``U_VS_ANEXO_PDF`` / ``U_VS_ANEXO_XML``).
|
||||||
|
"""
|
||||||
|
RUTA_RED_FACTURACION = os.getenv("RUTA_RED_FACTURACION", "")
|
||||||
|
RUC_EMPRESA = os.getenv("RUC_EMPRESA", "")
|
||||||
|
|
||||||
|
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", "")
|
||||||
|
serie = data.get("serie", "")
|
||||||
|
numero = data.get("numero", "")
|
||||||
|
xml_path = data.get("ruta_xml")
|
||||||
|
pdf_path = data.get("ruta_pdf")
|
||||||
|
|
||||||
|
try:
|
||||||
|
fecha = datetime.strptime(data.get("fecha_emision", ""), "%Y-%m-%d")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
log(f"⚠️ No se pudo copiar a red: fecha_emision inválida ({data.get('fecha_emision')})", 3, severity=logging.WARNING)
|
||||||
|
return
|
||||||
|
|
||||||
|
carpeta_destino = os.path.join(
|
||||||
|
RUTA_RED_FACTURACION,
|
||||||
|
str(fecha.year), str(fecha.month), str(fecha.day),
|
||||||
|
tipo, RUC_EMPRESA, ruc_emisor,
|
||||||
|
)
|
||||||
|
# Ruta guardada en el draft SAP (U_VS_ANEXO_PDF/XML): siempre estilo
|
||||||
|
# Windows/UNC con backslash, sin importar el separador del SO donde
|
||||||
|
# corre el script (evita mezclar "\\" y "/" al usar os.path.join en
|
||||||
|
# un host no-Windows).
|
||||||
|
carpeta_destino_unc = ntpath.join(
|
||||||
|
RUTA_RED_FACTURACION,
|
||||||
|
str(fecha.year), str(fecha.month), str(fecha.day),
|
||||||
|
tipo, RUC_EMPRESA, ruc_emisor,
|
||||||
|
)
|
||||||
|
nombre_base = f"{RUC_EMPRESA}-{tipo}-{serie}-{numero}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.makedirs(carpeta_destino, exist_ok=True)
|
||||||
|
|
||||||
|
if pdf_path and os.path.exists(pdf_path):
|
||||||
|
ruta_red_pdf = os.path.join(carpeta_destino, f"{nombre_base}_FormatoImpreso.pdf")
|
||||||
|
shutil.copy2(pdf_path, ruta_red_pdf)
|
||||||
|
data["ruta_red_pdf"] = ntpath.join(carpeta_destino_unc, f"{nombre_base}_FormatoImpreso.pdf")
|
||||||
|
|
||||||
|
if xml_path and os.path.exists(xml_path):
|
||||||
|
ruta_red_xml = os.path.join(carpeta_destino, f"{nombre_base}.xml")
|
||||||
|
shutil.copy2(xml_path, ruta_red_xml)
|
||||||
|
data["ruta_red_xml"] = ntpath.join(carpeta_destino_unc, f"{nombre_base}.xml")
|
||||||
|
|
||||||
|
log(f"📁 Copiado a red: {carpeta_destino}", 3)
|
||||||
|
except OSError as e:
|
||||||
|
log(f"⚠️ Error copiando a ruta de red: {e}", 3, severity=logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def procesar_pdfs(email: dict, download_folder: str) -> dict:
|
||||||
|
"""
|
||||||
|
Procesa todos los PDF de los adjuntos del correo.
|
||||||
|
|
||||||
|
Por cada PDF:
|
||||||
|
1. Extrae sus datos estructurados via ``pdf_utilities.extraer_datos_pdf``.
|
||||||
|
2. Renombra el archivo al formato canónico
|
||||||
|
``{ruc}-{tipo}-{serie}-{numero}.pdf``.
|
||||||
|
3. Almacena los datos extraídos en un cache indexado por el nombre
|
||||||
|
canónico.
|
||||||
|
|
||||||
|
Los datos extraídos también se acumulan en ``email["datos_pdf"]``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — Cache ``{nombre_canonico.pdf: datos_extraídos}``.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Si el PDF es ``F002-486.pdf`` y la extracción devuelve
|
||||||
|
``ruc_emisor=20537929520, tipo_documento=FACTURA``::
|
||||||
|
|
||||||
|
cache = procesar_pdfs(email, download_folder)
|
||||||
|
# → {"20537929520-01-F002-486.pdf": {...datos...}}
|
||||||
|
# El archivo se renombra a 20537929520-01-F002-486.pdf
|
||||||
|
"""
|
||||||
|
carpeta = ruta_carpeta(download_folder)
|
||||||
|
pdf_cache: dict[str, dict] = {}
|
||||||
|
datos_pdf = []
|
||||||
|
|
||||||
|
for idx, adjunto in enumerate(email.get("adjuntos", [])):
|
||||||
|
if not adjunto.lower().endswith(".pdf"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
pdf_path = os.path.join(carpeta, adjunto)
|
||||||
|
data = pdf_utilities.extraer_datos_pdf(pdf_path)
|
||||||
|
|
||||||
|
if data and not data.get("error"):
|
||||||
|
nuevo_path = pdf_utilities.renombrar_pdf(pdf_path, data)
|
||||||
|
if nuevo_path != pdf_path:
|
||||||
|
nuevo_nombre = os.path.basename(nuevo_path)
|
||||||
|
email["adjuntos"][idx] = nuevo_nombre
|
||||||
|
data["archivo"] = nuevo_nombre
|
||||||
|
|
||||||
|
datos_pdf.append(data)
|
||||||
|
|
||||||
|
canonical = os.path.basename(nuevo_path)
|
||||||
|
pdf_cache[canonical] = data
|
||||||
|
log(f"📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})", 3)
|
||||||
|
else:
|
||||||
|
log(f"⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}", 2, severity=logging.WARNING)
|
||||||
|
|
||||||
|
if datos_pdf:
|
||||||
|
email["datos_pdf"] = datos_pdf
|
||||||
|
|
||||||
|
return pdf_cache
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def procesar_xmls(email: dict, download_folder: str, pdf_cache: dict) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Procesa todos los XML de los adjuntos del correo.
|
||||||
|
|
||||||
|
Por cada XML:
|
||||||
|
1. Extrae datos estructurados via ``sunat_schema.extraer_datos_xml``.
|
||||||
|
2. Construye el nombre canónico del PDF asociado
|
||||||
|
(``{ruc}-{tipo}-{serie}-{numero}.pdf``) y lo busca en el cache.
|
||||||
|
3. Si el XML no trae ``documentos_referencia``, los completa desde
|
||||||
|
el PDF cache (usando ``pdf_utilities.extraer_referencias``).
|
||||||
|
4. Asigna las guías de remisión (``guias_remision[]``) y
|
||||||
|
transportista (``guia_transportista``) a cada línea de detalle desde
|
||||||
|
``notes_text`` o ``documentos_referencia``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — Lista de documentos extraídos listos para
|
||||||
|
``guardar_resultado``.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
XML con ``DespatchDocumentReference`` ID sucio
|
||||||
|
``"GRRE: T001-127773 / GRET :EG03-11"``::
|
||||||
|
|
||||||
|
docs = procesar_xmls(email, download_folder, pdf_cache)
|
||||||
|
# → [{
|
||||||
|
# "lineas": [{
|
||||||
|
# "guias_remision": [{"tipo":"09","serie":"T001","numero":"127773"}],
|
||||||
|
# "guia_transportista": {"tipo":"31","serie":"EG03","numero":"11"}
|
||||||
|
# }]
|
||||||
|
# }]
|
||||||
|
"""
|
||||||
|
carpeta = ruta_carpeta(download_folder)
|
||||||
|
documentos = []
|
||||||
|
|
||||||
|
last_idx = len(email["adjuntos"]) - 1
|
||||||
|
for idx, a in enumerate(email["adjuntos"]):
|
||||||
|
log(a, 2, last=(idx == last_idx))
|
||||||
|
|
||||||
|
if not a.lower().endswith(".xml"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
xml_path = os.path.join(carpeta, a)
|
||||||
|
log(f"📄 Procesando XML: {a}", 3)
|
||||||
|
data = sunat_schema.extraer_datos_xml(xml_path)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
log("ℹ️ XML sin datos (no es factura tipo 01 o guía 31)", 3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# CDR solo se renombra, no se procesa como documento
|
||||||
|
if data.get("es_cdr"):
|
||||||
|
prefijo = "CDR-"
|
||||||
|
cdr_name = f"{prefijo}{data['ruc_emisor']}-{data['tipo']}-{data['serie']}-{data['numero']}.xml"
|
||||||
|
cdr_dest = os.path.join(carpeta, cdr_name)
|
||||||
|
if cdr_dest != xml_path:
|
||||||
|
if os.path.exists(cdr_dest):
|
||||||
|
os.remove(cdr_dest)
|
||||||
|
os.rename(xml_path, cdr_dest)
|
||||||
|
email["adjuntos"][idx] = cdr_name
|
||||||
|
log(f"ℹ️ {data.get('serie','?')}-{data.get('numero','?')} (CDR) — solo renombrado", 3)
|
||||||
|
continue
|
||||||
|
|
||||||
|
data["ruta_xml"] = xml_path
|
||||||
|
|
||||||
|
# Renombrar XML a {prefijo}{ruc}-{tipo}-{serie}-{numero}.xml
|
||||||
|
prefijo = ""
|
||||||
|
xml_canonical = f"{prefijo}{data.get('ruc_emisor', '')}-{data.get('tipo', '')}-{data.get('serie', '')}-{data.get('numero', '')}.xml"
|
||||||
|
xml_destino = os.path.join(carpeta, xml_canonical)
|
||||||
|
if xml_destino != xml_path:
|
||||||
|
if os.path.exists(xml_destino):
|
||||||
|
os.remove(xml_destino)
|
||||||
|
os.rename(xml_path, xml_destino)
|
||||||
|
email["adjuntos"][idx] = xml_canonical
|
||||||
|
data["ruta_xml"] = xml_destino
|
||||||
|
xml_path = xml_destino
|
||||||
|
|
||||||
|
# ruta_pdf: construir nombre canónico {ruc}-{tipo}-{serie}-{numero}.pdf
|
||||||
|
pdf_canonical = f"{data.get('ruc_emisor', '')}-{data.get('tipo', '')}-{data.get('serie', '')}-{data.get('numero', '')}.pdf"
|
||||||
|
pdf_path = os.path.join(carpeta, pdf_canonical)
|
||||||
|
if not os.path.exists(pdf_path):
|
||||||
|
# Fallback: reemplazar extensión .xml por .pdf
|
||||||
|
pdf_path = os.path.splitext(xml_path)[0] + ".pdf"
|
||||||
|
pdf_path = pdf_path if os.path.exists(pdf_path) else None
|
||||||
|
data["ruta_pdf"] = pdf_path
|
||||||
|
|
||||||
|
copiar_a_red(data)
|
||||||
|
|
||||||
|
# Cruzar documentos_referencia desde el PDF renombrado (pdf_cache por nombre canónico)
|
||||||
|
if not data.get("documentos_referencia"):
|
||||||
|
pdf_data = pdf_cache.get(pdf_canonical)
|
||||||
|
if pdf_data:
|
||||||
|
refs = pdf_utilities.extraer_referencias(pdf_data.get("items", []))
|
||||||
|
if refs:
|
||||||
|
data["documentos_referencia"] = refs
|
||||||
|
log(f"🔄 documentos_referencia completados desde PDF ({len(refs)} refs)", 3)
|
||||||
|
if not data.get("documentos_referencia"):
|
||||||
|
for pdf_data in email.get("datos_pdf", []):
|
||||||
|
refs = pdf_utilities.extraer_referencias(pdf_data.get("items", []))
|
||||||
|
if refs:
|
||||||
|
data["documentos_referencia"] = refs
|
||||||
|
log(f"🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)", 3)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Asignar guias_remision[] y guia_transportista por línea
|
||||||
|
# Prioridad: notes_text (ya parseado) > documentos_referencia
|
||||||
|
refs_09 = [r for r in data.get("documentos_referencia", []) if r.get("tipo") == "09"]
|
||||||
|
refs_31 = [r for r in data.get("documentos_referencia", []) if r.get("tipo") == "31"]
|
||||||
|
|
||||||
|
for i, linea in enumerate(data.get("lineas", [])):
|
||||||
|
gr = linea.get("guias_remision") or []
|
||||||
|
gt = linea.get("guia_transportista")
|
||||||
|
|
||||||
|
if not gr and i < len(refs_09):
|
||||||
|
es_ultima = i == len(data["lineas"]) - 1
|
||||||
|
if es_ultima:
|
||||||
|
gr = list(refs_09[i:])
|
||||||
|
else:
|
||||||
|
gr = [dict(refs_09[i])]
|
||||||
|
if not gt and i < len(refs_31):
|
||||||
|
gt = dict(refs_31[i])
|
||||||
|
|
||||||
|
linea["guias_remision"] = gr
|
||||||
|
if gt:
|
||||||
|
linea["guia_transportista"] = gt
|
||||||
|
|
||||||
|
log(f"✅ Datos extraídos: {data.get('serie')}-{data.get('numero')} ({data.get('tipo_descripcion', '?')})", 3)
|
||||||
|
documentos.append(data)
|
||||||
|
|
||||||
|
# Post-procesamiento: resolver GREs desde guías de transporte referenciadas
|
||||||
|
guia_lookup = {}
|
||||||
|
for doc in documentos:
|
||||||
|
if doc.get("tipo") in ("31", "09"):
|
||||||
|
guia_lookup[(doc["tipo"], doc["serie"], doc["numero"])] = doc
|
||||||
|
|
||||||
|
for doc in documentos:
|
||||||
|
if doc.get("tipo") != "01":
|
||||||
|
continue
|
||||||
|
for ref in doc.get("documentos_referencia", []):
|
||||||
|
if ref["tipo"] == "31":
|
||||||
|
guia_doc = guia_lookup.get(("31", ref["serie"], ref["numero"]))
|
||||||
|
if guia_doc:
|
||||||
|
gre_list = [g for g in guia_doc.get("documentos_referencia", []) if g.get("tipo") == "09"]
|
||||||
|
if gre_list:
|
||||||
|
log(f"🔗 Resueltas {len(gre_list)} GRE(s) desde guía {ref['serie']}-{ref['numero']}", 3)
|
||||||
|
for i, linea in enumerate(doc.get("lineas", [])):
|
||||||
|
if not linea.get("guias_remision"):
|
||||||
|
es_ultima = i == len(doc["lineas"]) - 1
|
||||||
|
if es_ultima:
|
||||||
|
linea["guias_remision"] = gre_list[i:]
|
||||||
|
elif i < len(gre_list):
|
||||||
|
linea["guias_remision"] = [gre_list[i]]
|
||||||
|
if not linea.get("guia_transportista"):
|
||||||
|
linea["guia_transportista"] = dict(ref)
|
||||||
|
|
||||||
|
return documentos
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def completar_guias(documentos: list[dict], pdf_cache: dict) -> int:
|
||||||
|
"""
|
||||||
|
Completa guías faltantes en XML desde datos del PDF.
|
||||||
|
|
||||||
|
Para cada documento en ``documentos``, verifica si todas las líneas
|
||||||
|
tienen ``guias_remision``. Si faltan, busca el PDF correspondiente en
|
||||||
|
el cache y copia las guías de las líneas del PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
documentos: Lista de documentos extraídos de XML.
|
||||||
|
pdf_cache: Cache de datos PDF (canonical_name → datos).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int — Número de guías copiadas.
|
||||||
|
"""
|
||||||
|
total = 0
|
||||||
|
for xd in documentos:
|
||||||
|
has_all = all(
|
||||||
|
len(linea.get("guias_remision") or []) > 0
|
||||||
|
for linea in xd.get("lineas", [])
|
||||||
|
)
|
||||||
|
if has_all:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pdf_canonical = f"{xd.get('ruc_emisor', '')}-{xd.get('tipo', '')}-{xd.get('serie', '')}-{xd.get('numero', '')}.pdf"
|
||||||
|
pd = pdf_cache.get(pdf_canonical)
|
||||||
|
if not pd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for i, xl in enumerate(xd.get("lineas", [])):
|
||||||
|
if xl.get("guias_remision"):
|
||||||
|
continue
|
||||||
|
pl = pd["items"][i] if i < len(pd.get("items", [])) else {}
|
||||||
|
p_gr = pl.get("guias_remision") or []
|
||||||
|
if isinstance(p_gr[0], str) if p_gr else False:
|
||||||
|
# Normalizar formato string → dict
|
||||||
|
p_gr = [
|
||||||
|
{"tipo": "09", "serie": m.group(1).upper(), "numero": m.group(2).lstrip("0") or "0"}
|
||||||
|
for g in p_gr
|
||||||
|
for m in [re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", g)]
|
||||||
|
if m
|
||||||
|
]
|
||||||
|
if p_gr:
|
||||||
|
xl["guias_remision"] = list(p_gr)
|
||||||
|
total += len(p_gr)
|
||||||
|
if not xl.get("guia_transportista") and pl.get("guia_transportista"):
|
||||||
|
gt = pl["guia_transportista"]
|
||||||
|
if isinstance(gt, str):
|
||||||
|
m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", gt)
|
||||||
|
if m:
|
||||||
|
xl["guia_transportista"] = {
|
||||||
|
"tipo": "31",
|
||||||
|
"serie": m.group(1).upper(),
|
||||||
|
"numero": m.group(2).lstrip("0") or "0",
|
||||||
|
}
|
||||||
|
elif isinstance(gt, dict):
|
||||||
|
xl["guia_transportista"] = dict(gt)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def validar_guias(documentos: list[dict]) -> None:
|
||||||
|
for doc in documentos:
|
||||||
|
for i, linea in enumerate(doc.get("lineas", [])):
|
||||||
|
for j, g in enumerate(linea.get("guias_remision") or []):
|
||||||
|
if not isinstance(g, dict) or not all(k in g and g[k] for k in ("tipo", "serie", "numero")):
|
||||||
|
log(f"⚠️ guias_remision[{j}] inválida en {doc.get('serie','?')}-{doc.get('numero','?')} línea {i}: {g}", 2, severity=logging.WARNING)
|
||||||
|
gt = linea.get("guia_transportista")
|
||||||
|
if gt and (not isinstance(gt, dict) or not all(k in gt and gt[k] for k in ("tipo", "serie", "numero"))):
|
||||||
|
log(f"⚠️ guia_transportista inválida en {doc.get('serie','?')}-{doc.get('numero','?')} línea {i}: {gt}", 2, severity=logging.WARNING)
|
||||||
+445
@@ -0,0 +1,445 @@
|
|||||||
|
"""Validación de precios contra SAP y construcción del draft SAP B1."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import sapb1_manager
|
||||||
|
import hana_manager
|
||||||
|
from pipeline_utils import importes_coinciden, 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
|
||||||
|
la condición de pago (PaymentTermsTypes) del proveedor.
|
||||||
|
|
||||||
|
Usa ``NumberOfAdditionalDays`` como días a sumar; si ese valor es 0,
|
||||||
|
usa ``NumberOfAdditionalMonths * 30`` en su lugar.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fecha_base: Fecha de contabilización, formato "YYYY-MM-DD".
|
||||||
|
condicion_pago: Resultado de ``sapb1_manager.obtener_condicion_pago``
|
||||||
|
(campos ``NumberOfAdditionalDays``, ``NumberOfAdditionalMonths``),
|
||||||
|
o None si el proveedor no tiene condición de pago configurada.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str — Fecha de vencimiento, formato "YYYY-MM-DD". Si
|
||||||
|
``condicion_pago`` es None, retorna ``fecha_base`` sin cambios.
|
||||||
|
"""
|
||||||
|
fecha = datetime.strptime(fecha_base, "%Y-%m-%d")
|
||||||
|
if not condicion_pago:
|
||||||
|
return fecha.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
dias = int(condicion_pago.get("NumberOfAdditionalDays") or 0)
|
||||||
|
if dias == 0:
|
||||||
|
dias = int(condicion_pago.get("NumberOfAdditionalMonths") or 0) * 30
|
||||||
|
fecha += timedelta(days=dias)
|
||||||
|
|
||||||
|
return fecha.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def build_sap_draft(factura: dict, resultados_val: list[dict]) -> dict:
|
||||||
|
"""
|
||||||
|
Construye el payload JSON para crear un Draft en SAP B1 Service Layer
|
||||||
|
(endpoint ``POST /Drafts``).
|
||||||
|
|
||||||
|
Flujo:
|
||||||
|
1. Toma los datos de cabecera de la factura (RUCS, fechas, totales).
|
||||||
|
2. Determina el ``ItemCode`` según la fecha de emisión:
|
||||||
|
``C103002-A`` si es de un mes anterior, ``C103002`` si es del
|
||||||
|
mes actual.
|
||||||
|
3. Prepara las cuotas de detracción (``DocumentInstallments``):
|
||||||
|
si la factura tiene detracción, genera dos cuotas (detracción y
|
||||||
|
neto); si no, una sola cuota por el total.
|
||||||
|
4. Por cada línea de factura, itera sobre las guías validadas
|
||||||
|
(``guias_lt[]`` de ``resultados_val``) y genera una línea de
|
||||||
|
documento (``DocumentLine``) por cada guía con:
|
||||||
|
- ``ItemCode``, ``Quantity``, ``PriceAfterVAT``,
|
||||||
|
- ``WarehouseCode``, ``U_IDC_GRFLETE`` y campos fijos.
|
||||||
|
Cuando la validación es ``ok=True`` usa los valores de SAP
|
||||||
|
(``draft_quantity``, ``draft_price``); cuando no, usa los del XML.
|
||||||
|
5. Calcula ``U_IDC_VALREF`` como la suma de ``valor_referencial``
|
||||||
|
(extraído del XML SUNAT por ``sunat_schema``) de todas las
|
||||||
|
líneas de la factura.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
factura: Diccionario con los datos de la factura extraídos del
|
||||||
|
XML (claves: ``serie``, ``numero``, ``tipo``, ``lineas[]``,
|
||||||
|
``guias_remision[]``, ``tiene_detraccion``, etc.).
|
||||||
|
resultados_val: Lista de resultados de ``validate_prices``,
|
||||||
|
uno por línea de factura. Cada elemento contiene
|
||||||
|
``guias_lt[]``, ``ok``, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con la estructura completa del draft SAP B1 lista para
|
||||||
|
enviar a ``POST /Drafts``.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Factura F002-486 con guía T001-127773 y SAP devuelve
|
||||||
|
``FL10007 qty=350 pav=1.3396``::
|
||||||
|
|
||||||
|
draft = build_sap_draft(factura, resultados_val)
|
||||||
|
# → {
|
||||||
|
# "DocType": "dDocument_Items",
|
||||||
|
# "CardCode": "PL20537929520",
|
||||||
|
# "DocumentLines": [{
|
||||||
|
# "ItemCode": "C103002",
|
||||||
|
# "Quantity": 350,
|
||||||
|
# "PriceAfterVAT": 1.3396,
|
||||||
|
# "U_IDC_GRFLETE": "09T001-127773",
|
||||||
|
# ...
|
||||||
|
# }],
|
||||||
|
# "DocumentInstallments": [...],
|
||||||
|
# "U_IDC_VALREF": 31181.5,
|
||||||
|
# ...
|
||||||
|
# }
|
||||||
|
"""
|
||||||
|
serie = factura.get("serie", "")
|
||||||
|
numero = factura.get("numero", "")
|
||||||
|
tipo = factura.get("tipo", "01")
|
||||||
|
total = factura.get("total_factura", 0) or 0
|
||||||
|
tiene_detraccion = factura.get("tiene_detraccion", False)
|
||||||
|
codigo_detraccion = factura.get("codigo_detraccion", "")
|
||||||
|
porcentaje_detraccion = factura.get("porcentaje_detraccion", 0) or 0
|
||||||
|
monto_detraccion = factura.get("monto_detraccion", 0) or 0
|
||||||
|
fecha_emision = factura.get("fecha_emision", "")
|
||||||
|
ruc_emisor = factura.get("ruc_emisor", "")
|
||||||
|
razon_social_emisor = factura.get("razon_social_emisor", "")
|
||||||
|
|
||||||
|
card_code = f"PL{ruc_emisor}"
|
||||||
|
|
||||||
|
fecha_contabilizacion = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
emision = datetime.strptime(fecha_emision, "%Y-%m-%d")
|
||||||
|
item_code = "C103002-A" if (emision.year, emision.month) < (datetime.now().year, datetime.now().month) else "C103002"
|
||||||
|
|
||||||
|
condicion_pago = sapb1_manager.obtener_condicion_pago(card_code)
|
||||||
|
fecha_vencimiento = calcular_fecha_vencimiento(fecha_contabilizacion, condicion_pago)
|
||||||
|
|
||||||
|
|
||||||
|
if tiene_detraccion:
|
||||||
|
# neto = round(total - monto_detraccion, 2)
|
||||||
|
monto_detraccion = math.ceil(monto_detraccion)
|
||||||
|
neto = total - monto_detraccion
|
||||||
|
installments = [
|
||||||
|
{"DueDate": fecha_vencimiento, "Total": monto_detraccion, "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
|
||||||
|
{"DueDate": fecha_vencimiento, "Total": round(neto,2), "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
installments = [
|
||||||
|
{"DueDate": fecha_vencimiento, "Total": round(total, 2), "InstallmentId": 1, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
|
||||||
|
]
|
||||||
|
|
||||||
|
doc_lines = []
|
||||||
|
guia_referencia = ""
|
||||||
|
|
||||||
|
for i, linea in enumerate(factura.get("lineas", [])):
|
||||||
|
resultado_validacion = resultados_val[i] if i < len(resultados_val) else None
|
||||||
|
guias_lt = (resultado_validacion or {}).get("guias_lt", [])
|
||||||
|
if not guias_lt:
|
||||||
|
continue
|
||||||
|
|
||||||
|
guia_referencia = f"{guias_lt[0]['guia']['tipo']}{guias_lt[0]['guia']['serie']}-{guias_lt[0]['guia']['numero']}"
|
||||||
|
|
||||||
|
for item in guias_lt:
|
||||||
|
codigo_guia = f"{item['guia']['tipo']}{item['guia']['serie']}-{item['guia']['numero']}"
|
||||||
|
lt_val = math.trunc(item["draft_price"] * 1_000_000) / 1_000_000
|
||||||
|
qty = item.get("draft_quantity", int(item.get("quantity", 1)))
|
||||||
|
|
||||||
|
doc_lines.append({
|
||||||
|
"ItemCode": item_code,
|
||||||
|
"Quantity": qty,
|
||||||
|
"PriceAfterVAT": lt_val,
|
||||||
|
"WarehouseCode": item.get("warehouse_code", "A-CENTRA"),
|
||||||
|
"CostingCode": "PRO",
|
||||||
|
"CostingCode2": "PRO03",
|
||||||
|
"TaxCode": "I18",
|
||||||
|
"FreeText": linea.get("descripcion", "")[:100],
|
||||||
|
"MeasureUnit": "UND",
|
||||||
|
"U_BPP_CTAD": "9231101",
|
||||||
|
"U_BPP_OPER": "A",
|
||||||
|
"U_tipoOpT12": "02",
|
||||||
|
"U_VS_ONEROSO": "1",
|
||||||
|
"U_VS_TIPAFE": "10",
|
||||||
|
"U_IDC_GRFLETE": codigo_guia,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
valor_referencial = sum(l.get("valor_referencial") or 0 for l in factura.get("lineas", []))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"DocType": "dDocument_Items",
|
||||||
|
"DocDate": fecha_contabilizacion,
|
||||||
|
"DocDueDate": fecha_vencimiento,
|
||||||
|
"TaxDate": fecha_emision,
|
||||||
|
"CardCode": card_code,
|
||||||
|
"NumAtCard": f"{tipo}{serie}-{numero}",
|
||||||
|
"U_IDC_NUMGR": "" if not guia_referencia else guia_referencia,
|
||||||
|
"DocCurrency": "SOL",
|
||||||
|
"Comments": f"Factura {serie}-{numero} / {razon_social_emisor}"[:254],
|
||||||
|
"JournalMemo": razon_social_emisor[:254],
|
||||||
|
"DocObjectCode": "oPurchaseInvoices",
|
||||||
|
"U_BPV_SERI": f"{serie}-01",
|
||||||
|
"U_VS_NATNUM": "T",
|
||||||
|
"U_BPV_NCON2": numero,
|
||||||
|
"U_BPP_MDTD": tipo,
|
||||||
|
"U_BPP_MDBI": "A",
|
||||||
|
"U_BPP_MDCD": numero,
|
||||||
|
"U_BPP_MDSD": serie,
|
||||||
|
"U_VS_CODSER": f"{codigo_detraccion}02" if codigo_detraccion else "",
|
||||||
|
"U_VS_AFEDET": "Y" if tiene_detraccion else "N",
|
||||||
|
"U_VS_PORDET": porcentaje_detraccion,
|
||||||
|
"U_VS_APLANT": "Y",
|
||||||
|
"U_BPP_MDSD2": serie,
|
||||||
|
"U_IDC_FLETE": "02",
|
||||||
|
"U_IDC_LINEANE": "01",
|
||||||
|
"U_IDC_VALREF": valor_referencial,
|
||||||
|
"U_VS_ANEXO_PDF": factura.get("ruta_red_pdf", ""),
|
||||||
|
"U_VS_ANEXO_XML": factura.get("ruta_red_xml", ""),
|
||||||
|
"DocumentLines": doc_lines,
|
||||||
|
"DocumentInstallments": installments,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def validate_prices(factura: dict) -> tuple[bool, list[dict]]:
|
||||||
|
"""
|
||||||
|
Valida los precios de cada guía contra DeliveryNotes en SAP y
|
||||||
|
prepara los datos necesarios para construir el draft.
|
||||||
|
|
||||||
|
Flujo:
|
||||||
|
1. Obtiene el maestro de zonas SAP (``U_IDC_ZONAS``) para resolver
|
||||||
|
el nombre de zona a partir de ``U_IDC_DESTINO``.
|
||||||
|
2. Por cada línea de factura:
|
||||||
|
a. Itera sobre sus ``guias_remision[]``.
|
||||||
|
b. Para cada guía consulta SAP via
|
||||||
|
``sapb1_manager.get_delivery_notes``.
|
||||||
|
c. Dentro de cada DeliveryNote, suma los ``LineTotal``,
|
||||||
|
``PriceAfterVAT`` y obtiene la máxima ``Quantity`` de las
|
||||||
|
líneas cuyo ``ItemCode`` coincide con ``FL\\d``.
|
||||||
|
d. Acumula los resultados en ``guias_lt[]`` (uno por guía) con
|
||||||
|
``line_total``, ``quantity``, ``price_after_vat_sum``,
|
||||||
|
``warehouse_code`` y ``zona``.
|
||||||
|
3. Determina si los precios coinciden (``ok``):
|
||||||
|
``importes_coinciden(total_linea, sap_total)``: la diferencia, redondeada a
|
||||||
|
2 decimales, no supera 1 céntimo.
|
||||||
|
4. Fija ``draft_quantity`` y ``draft_price`` de cada guía:
|
||||||
|
si ``ok``, usa SAP (``int(quantity)`` y ``price_after_vat_sum``);
|
||||||
|
si no, usa el XML (``int(cantidad)`` y ``total_linea / cantidad``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
factura: Diccionario con los datos de la factura (debe contener
|
||||||
|
``lineas[]``, y cada línea debe tener ``guias_remision[]``
|
||||||
|
y ``total_linea``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[bool, list[dict]]:
|
||||||
|
- ``todas_coinciden``: True si TODAS las líneas tienen
|
||||||
|
``ok=True``.
|
||||||
|
- ``resultados``: Lista de resultados por línea, cada uno
|
||||||
|
con claves: ``grflete``, ``ok``, ``draft_price``,
|
||||||
|
``sap_price``, ``label``, ``warehouse_code``, ``guias_lt[]``
|
||||||
|
(y ``draft_cantidad`` en las líneas sin validar).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Factura con guía ``T001-127773`` y SAP devuelve
|
||||||
|
``FL10007 qty=350 pav=1.3396, FL20007 qty=350 pav=0``::
|
||||||
|
|
||||||
|
ok, resultados = validate_prices(factura)
|
||||||
|
# → ok = False (397.34 != 633.47)
|
||||||
|
# → resultados[0]["guias_lt"][0] = {
|
||||||
|
# "guia": {"tipo":"09","serie":"T001","numero":"127773"},
|
||||||
|
# "line_total": 397.34,
|
||||||
|
# "quantity": 350.0,
|
||||||
|
# "price_after_vat_sum": 1.3396,
|
||||||
|
# "zona": "CAÑETE",
|
||||||
|
# "warehouse_code": "A-CENTRA"
|
||||||
|
# }
|
||||||
|
"""
|
||||||
|
resultados = []
|
||||||
|
factura_lines = factura.get("lineas", [])
|
||||||
|
|
||||||
|
# listado de zonas para obtener su descripción
|
||||||
|
zonas_raw = sapb1_manager.get_zonas()
|
||||||
|
zonas = {z["Code"]: z["Name"] for z in zonas_raw}
|
||||||
|
|
||||||
|
|
||||||
|
for fl in factura_lines:
|
||||||
|
# por cada linea de factura se obtiene
|
||||||
|
# cantidad del xml de la factura
|
||||||
|
cantidad = float(fl.get("cantidad", 0))
|
||||||
|
# total de linea antes de impuesto del xml de la factura
|
||||||
|
total_linea = float(fl.get("total_linea", 0) or 0)
|
||||||
|
# lista de guias asociadas a la linea de factura de xml o pdf
|
||||||
|
guias = fl.get("guias_remision", [])
|
||||||
|
|
||||||
|
# dificil que la cantidad sea 0, pero esos casos los descartamos
|
||||||
|
if cantidad == 0:
|
||||||
|
resultados.append({"grflete": "-", "ok": False, "draft_price": None, "sap_price": None, "label": "Cantidad cero, sin validar", "warehouse_code": "-", "draft_cantidad": cantidad, "guias_lt": []})
|
||||||
|
continue
|
||||||
|
# si no hay guia asociada, no se puede validar, lo descartamos
|
||||||
|
if not guias:
|
||||||
|
resultados.append({"grflete": "-", "ok": False, "draft_price": None, "sap_price": None, "label": "Sin guía de referencia", "warehouse_code": "-", "draft_cantidad": cantidad, "guias_lt": []})
|
||||||
|
continue
|
||||||
|
|
||||||
|
guias_lt = []
|
||||||
|
all_wh = set()
|
||||||
|
all_ok = True
|
||||||
|
|
||||||
|
# una linea de la factura puede tener varias guias de remision
|
||||||
|
# hay que validar cada una de ellas
|
||||||
|
for guia in guias:
|
||||||
|
codigo_guia = f"{guia['tipo']}{guia['serie']}-{guia['numero']}"
|
||||||
|
try:
|
||||||
|
delivery_notes = sapb1_manager.get_delivery_notes(
|
||||||
|
tipo=guia["tipo"], serie=guia["serie"], numero=guia["numero"]
|
||||||
|
)
|
||||||
|
|
||||||
|
wh_set = set()
|
||||||
|
suma_line_total = 0.0
|
||||||
|
max_qty = 0.0
|
||||||
|
sum_pav = 0.0
|
||||||
|
weight1 = 0.0
|
||||||
|
zona = ""
|
||||||
|
|
||||||
|
for dn in delivery_notes:
|
||||||
|
# obtenemos la zona de destino del documento
|
||||||
|
zona = zonas.get(dn.get("U_IDC_DESTINO",""),"")
|
||||||
|
# obtenemos el almacen de la primera linea
|
||||||
|
first_line = dn.get("DocumentLines", [None])[0]
|
||||||
|
if first_line:
|
||||||
|
wh = first_line.get("WarehouseCode", "")
|
||||||
|
if wh:
|
||||||
|
wh_set.add(wh)
|
||||||
|
|
||||||
|
# obtenemos el peso de la primera linea de la guia de remision
|
||||||
|
# que representa el peso de la guia de remisión del articulo inventariable
|
||||||
|
# y lo vamos acumulando para obtener el peso total de la linea de la factura
|
||||||
|
weight1 += float(first_line.get("Weight1", 0) or 0)
|
||||||
|
# recorremos cada linea del documento
|
||||||
|
for linea_detalle_dict in dn.get("DocumentLines", []):
|
||||||
|
# solo procesamos lineas que empiecen con FL\d
|
||||||
|
if not re.match(r"FL\d", linea_detalle_dict.get("ItemCode", "")):
|
||||||
|
continue
|
||||||
|
# obtenemos el total de linea
|
||||||
|
line_total = float(linea_detalle_dict.get("LineTotal", 0) or 0)
|
||||||
|
# obtenemos el precio después de impuesto
|
||||||
|
price_after_vat = float(linea_detalle_dict.get("PriceAfterVAT", 0) or 0)
|
||||||
|
# obtenemos la cantidad
|
||||||
|
quantity = float(linea_detalle_dict.get("Quantity", 0) or 0)
|
||||||
|
# sumamos el total de linea
|
||||||
|
suma_line_total += line_total
|
||||||
|
# obtenemos la cantidad maxima
|
||||||
|
max_qty = max(max_qty, quantity)
|
||||||
|
|
||||||
|
sum_pav += price_after_vat
|
||||||
|
log(f"└─ {dn.get('Series', '?')}-{dn.get('DocNum', '?')} | ItemCode: {linea_detalle_dict.get('ItemCode', '')} | PriceAfterVAT: {price_after_vat:.6f} | Quantity: {quantity:.6f} | LineTotal: {line_total:.6f}", 4)
|
||||||
|
|
||||||
|
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, "error": str(ex)})
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
sap_total = sum(item["line_total"] for item in guias_lt)
|
||||||
|
sin_sap = all(item["line_total"] == 0.0 and item["quantity"] == 0.0 for item in guias_lt)
|
||||||
|
ok = all_ok and importes_coinciden(total_linea, sap_total)
|
||||||
|
label = "Sin guías SAP" if sin_sap else "LineTotal (FL*) vs total_linea"
|
||||||
|
grflete_str = "; ".join(f"{item['guia']['tipo']}{item['guia']['serie']}-{item['guia']['numero']}" for item in guias_lt)
|
||||||
|
wh_str = ", ".join(sorted(all_wh)) if all_wh else "A-CENTRA"
|
||||||
|
|
||||||
|
for item in guias_lt:
|
||||||
|
if ok:
|
||||||
|
item["draft_quantity"] = int(item["quantity"])
|
||||||
|
item["draft_price"] = item["price_after_vat_sum"]
|
||||||
|
else:
|
||||||
|
item["draft_quantity"] = int(cantidad)
|
||||||
|
item["draft_price"] = float(total_linea) / max(float(cantidad), 1)
|
||||||
|
|
||||||
|
resultados.append({
|
||||||
|
"grflete": grflete_str,
|
||||||
|
"ok": ok,
|
||||||
|
"draft_price": total_linea,
|
||||||
|
"sap_price": sap_total,
|
||||||
|
"label": label,
|
||||||
|
"warehouse_code": wh_str,
|
||||||
|
"guias_lt": guias_lt,
|
||||||
|
})
|
||||||
|
|
||||||
|
if sin_sap:
|
||||||
|
log("⏭️ Sin guías en SAP — línea saltada", 3)
|
||||||
|
else:
|
||||||
|
log(f"Cantidad: {cantidad} | Total LineTotal (SAP): {sap_total:.6f} vs total_linea (XML): {total_linea:.6f}", 3)
|
||||||
|
if ok:
|
||||||
|
log(f"✅ Coincide: {total_linea:.6f} ≈ {sap_total:.6f}", 3)
|
||||||
|
else:
|
||||||
|
log(f"❌ NO coincide — XML: {total_linea:.6f} vs SAP: {sap_total:.6f}", 3, severity=logging.WARNING)
|
||||||
|
|
||||||
|
todas_coinciden = all(r["ok"] for r in resultados)
|
||||||
|
return todas_coinciden, resultados
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def obtener_valor_referencial_sap(factura: dict) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Ejecuta ``Sp_ObtenerValorReferencial`` en SAP HANA por cada guía de
|
||||||
|
remisión (tipo 09) única de la factura y acumula el resultado.
|
||||||
|
|
||||||
|
Una factura puede referenciar la misma guía desde más de una línea;
|
||||||
|
en ese caso solo se consulta una vez por guía.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
factura: Diccionario de la factura, con ``lineas[]`` y cada
|
||||||
|
línea con ``guias_remision[]`` (tipo 09).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] — uno por guía única, con ``serie``, ``numero`` y
|
||||||
|
``valor_sap``.
|
||||||
|
"""
|
||||||
|
vistos = set()
|
||||||
|
resultados = []
|
||||||
|
for linea in factura.get("lineas", []):
|
||||||
|
for guia in linea.get("guias_remision", []):
|
||||||
|
clave = (guia.get("serie"), guia.get("numero"))
|
||||||
|
if clave in vistos:
|
||||||
|
continue
|
||||||
|
vistos.add(clave)
|
||||||
|
valor_sap = hana_manager.obtener_valor_referencial(guia.get("serie", ""), guia.get("numero", ""))
|
||||||
|
resultados.append({"serie": guia.get("serie", ""), "numero": guia.get("numero", ""), "valor_sap": valor_sap})
|
||||||
|
log(f"└─ Guía 09{guia.get('serie')}-{guia.get('numero')} | Valor Referencial SAP: {valor_sap:.2f}", 3)
|
||||||
|
return resultados
|
||||||
+7
-4
@@ -1,6 +1,9 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
def descomprimir_adjuntos(emails: list[dict], ruta: str) -> list[dict]:
|
def descomprimir_adjuntos(emails: list[dict], ruta: str) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
@@ -16,7 +19,7 @@ def descomprimir_adjuntos(emails: list[dict], ruta: str) -> list[dict]:
|
|||||||
continue
|
continue
|
||||||
ruta_zip = os.path.join(ruta, adjunto)
|
ruta_zip = os.path.join(ruta, adjunto)
|
||||||
if not os.path.exists(ruta_zip):
|
if not os.path.exists(ruta_zip):
|
||||||
print(f"⚠️ No existe: {ruta_zip}")
|
logger.warning(f"⚠️ No existe: {ruta_zip}")
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(ruta_zip, "r") as zip_ref:
|
with zipfile.ZipFile(ruta_zip, "r") as zip_ref:
|
||||||
@@ -27,16 +30,16 @@ def descomprimir_adjuntos(emails: list[dict], ruta: str) -> list[dict]:
|
|||||||
]
|
]
|
||||||
zip_ref.extractall(ruta)
|
zip_ref.extractall(ruta)
|
||||||
nuevos_adjuntos.extend(nombres_extraidos)
|
nuevos_adjuntos.extend(nombres_extraidos)
|
||||||
print(
|
logger.info(
|
||||||
f"📦 {adjunto}: {len(nombres_extraidos)} archivos extraídos"
|
f"📦 {adjunto}: {len(nombres_extraidos)} archivos extraídos"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"⚠️ Error descomprimiendo {adjunto}: {e}")
|
logger.warning(f"⚠️ Error descomprimiendo {adjunto}: {e}")
|
||||||
email["adjuntos"].extend(nuevos_adjuntos)
|
email["adjuntos"].extend(nuevos_adjuntos)
|
||||||
return emails
|
return emails
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print(f"❌ Error general: {ex}")
|
logger.error(f"❌ Error general: {ex}")
|
||||||
return emails
|
return emails
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
+6
-5
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from hdbcli import dbapi
|
from hdbcli import dbapi
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CONFIGURACIÓN
|
# CONFIGURACIÓN
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -33,7 +34,7 @@ def get_connection():
|
|||||||
)
|
)
|
||||||
return conn
|
return conn
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ Error conectando a HANA: {e}")
|
logger.error(f" ❌ Error conectando a HANA: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -91,7 +92,7 @@ def _query_delivery_table(connection, tabla: str, tipo: str, serie: str, numero:
|
|||||||
results.append(registro)
|
results.append(registro)
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ Error en consulta HANA get_delivery ({tabla}): {e}")
|
logger.error(f" ❌ Error en consulta HANA get_delivery ({tabla}): {e}")
|
||||||
return []
|
return []
|
||||||
finally:
|
finally:
|
||||||
if cursor:
|
if cursor:
|
||||||
@@ -129,7 +130,7 @@ def obtener_valor_referencial(serie: str, numero: str) -> float:
|
|||||||
valor = dict(zip(columns, row)).get("ValorReferencial")
|
valor = dict(zip(columns, row)).get("ValorReferencial")
|
||||||
return float(valor) if valor is not None else 0.0
|
return float(valor) if valor is not None else 0.0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ❌ Error en consulta HANA obtener_valor_referencial: {e}")
|
logger.error(f" ❌ Error en consulta HANA obtener_valor_referencial: {e}")
|
||||||
return 0.0
|
return 0.0
|
||||||
finally:
|
finally:
|
||||||
if cursor:
|
if cursor:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import argparse
|
import argparse
|
||||||
@@ -5,16 +6,24 @@ from datetime import datetime, timedelta
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
import logger_manager
|
import logger_manager
|
||||||
|
from config_validator import verificar_configuracion
|
||||||
from pipeline import run_pipeline
|
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 = [
|
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
|
(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
|
(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
|
(14, 0, 18, 30, 300, "rapido"), # 14:00 - 18:30 -> cada 5 minutos
|
||||||
(18, 30, 21, 30, 3600, "full"), # 17:30 - 21:30 -> cada 60 minutos
|
(18, 30, 21, 30, 3600, "full"), # 18:30 - 21:30 -> cada 60 minutos
|
||||||
(0, 0, 5, 0, 60, "rapido"), # 00:00 - 05:00 -> cada 5 minutos
|
(21, 30, 24, 0, 3600, "full"), # 21:30 - 24:00 -> cada 60 minutos
|
||||||
]
|
]
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
|
def obtener_intervalo(dt: datetime) -> tuple[int, str] | None:
|
||||||
@@ -67,13 +76,14 @@ def main() -> None:
|
|||||||
|
|
||||||
# Ruta absoluta: no depende del directorio de trabajo desde el que se lance el proceso
|
# Ruta absoluta: no depende del directorio de trabajo desde el que se lance el proceso
|
||||||
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
|
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
|
||||||
|
verificar_configuracion()
|
||||||
|
|
||||||
EMAIL = os.getenv("EMAIL", "")
|
EMAIL = os.getenv("EMAIL", "")
|
||||||
DOWNLOAD_FOLDER = os.getenv("DOWNLOAD_FOLDER", "")
|
DOWNLOAD_FOLDER = os.getenv("DOWNLOAD_FOLDER", "")
|
||||||
OUTPUT_FOLDER = os.getenv("OUTPUT_FOLDER", "")
|
OUTPUT_FOLDER = os.getenv("OUTPUT_FOLDER", "")
|
||||||
|
|
||||||
print("🚀 Demonio iniciado")
|
logger.info("🚀 Demonio iniciado")
|
||||||
print("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -86,8 +96,8 @@ def main() -> None:
|
|||||||
|
|
||||||
intervalo, modo = resultado
|
intervalo, modo = resultado
|
||||||
|
|
||||||
print(
|
logger.info(
|
||||||
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
||||||
f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
|
f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,14 +106,14 @@ def main() -> None:
|
|||||||
duracion = (datetime.now() - inicio_ciclo).total_seconds()
|
duracion = (datetime.now() - inicio_ciclo).total_seconds()
|
||||||
|
|
||||||
if duracion > intervalo:
|
if duracion > intervalo:
|
||||||
print(
|
logger.warning(
|
||||||
f"⚠️ El ciclo demoró {duracion:.1f}s "
|
f"⚠️ El ciclo demoró {duracion:.1f}s "
|
||||||
f"(intervalo configurado {intervalo}s)"
|
f"(intervalo configurado {intervalo}s)"
|
||||||
)
|
)
|
||||||
|
|
||||||
dormir = max(0, intervalo - duracion)
|
dormir = max(0, intervalo - duracion)
|
||||||
|
|
||||||
print(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
|
logger.info(f"💤 Próximo ciclo [{modo}] en {dormir:.0f}s...")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|
||||||
@@ -111,25 +121,22 @@ def main() -> None:
|
|||||||
|
|
||||||
despertar = inicio_ciclo + timedelta(seconds=dormir)
|
despertar = inicio_ciclo + timedelta(seconds=dormir)
|
||||||
|
|
||||||
print(
|
logger.info(f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Fuera del horario.")
|
||||||
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
logger.info(f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}")
|
||||||
f"Fuera del horario.\n"
|
|
||||||
f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}"
|
|
||||||
)
|
|
||||||
|
|
||||||
time.sleep(dormir)
|
time.sleep(dormir)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\n🛑 Demonio detenido por el usuario.")
|
logger.info("🛑 Demonio detenido por el usuario.")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logger_manager.setup_logging()
|
logger_manager.setup_logging()
|
||||||
|
|
||||||
print("=" * 60)
|
logger.info("=" * 60)
|
||||||
print(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()})")
|
||||||
print(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
|
logger.info(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
|
||||||
print("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
main()
|
main()
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Cuerpos HTML de los correos de notificación."""
|
||||||
|
|
||||||
|
from pipeline_utils import importes_coinciden
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def build_validation_html(resultados: list[dict]) -> str:
|
||||||
|
"""
|
||||||
|
Genera una tabla HTML con el resultado de la validación de precios
|
||||||
|
para incluir en el cuerpo del email de notificación.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
resultados: Lista de resultados de ``validate_prices``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str — Código HTML de la tabla con columnas:
|
||||||
|
Guía, Estado, Draft Price, SAP, Almacén, Resultado.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Con un resultado ``ok=True`` y ``grflete="09T001-127773"``::
|
||||||
|
|
||||||
|
html = build_validation_html(resultados)
|
||||||
|
# → "<table ...><tr><td>09T001-127773</td><td>✅</td>...</tr></table>"
|
||||||
|
"""
|
||||||
|
rows = ""
|
||||||
|
for r in resultados:
|
||||||
|
icono = "✅" if r["ok"] else "❌"
|
||||||
|
dp = f"{r['draft_price']:.6f}" if r["draft_price"] is not None else "-"
|
||||||
|
sap_price_str = f"{r['sap_price']:.6f}" if r["sap_price"] is not None else "-"
|
||||||
|
rows += f"<tr><td>{r['grflete']}</td><td>{icono}</td><td>{dp}</td><td>{sap_price_str}</td><td>{r['warehouse_code']}</td><td>{r['label']}</td></tr>"
|
||||||
|
return (
|
||||||
|
"<table border='1' cellpadding='6' cellspacing='0' style='border-collapse:collapse;'>"
|
||||||
|
"<tr><th>Guía</th><th>Estado</th><th>Draft Price</th><th>SAP</th><th>Almacén</th><th>Resultado</th></tr>"
|
||||||
|
f"{rows}</table>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def build_valor_referencial_html(factura: dict, guias_valor_ref: list[dict]) -> str:
|
||||||
|
"""
|
||||||
|
Genera una tabla HTML comparando el valor referencial del XML de la
|
||||||
|
factura contra el valor referencial obtenido en SAP
|
||||||
|
(``Sp_ObtenerValorReferencial``) por cada guía de remisión, y el
|
||||||
|
total acumulado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
factura: Diccionario de la factura (usa ``lineas[]`` para sumar
|
||||||
|
``valor_referencial`` extraído del XML).
|
||||||
|
guias_valor_ref: Resultado de ``obtener_valor_referencial_sap``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str — Tabla HTML con una fila por guía, el total SAP, el valor
|
||||||
|
del XML y un ícono de coincidencia.
|
||||||
|
"""
|
||||||
|
valor_xml = sum(l.get("valor_referencial") or 0 for l in factura.get("lineas", []))
|
||||||
|
total_sap = sum(g["valor_sap"] for g in guias_valor_ref)
|
||||||
|
|
||||||
|
rows = "".join(
|
||||||
|
f"<tr><td>09{g['serie']}-{g['numero']}</td><td>{g['valor_sap']:.2f}</td></tr>"
|
||||||
|
for g in guias_valor_ref
|
||||||
|
)
|
||||||
|
coincide = importes_coinciden(valor_xml, total_sap)
|
||||||
|
icono = "✅" if coincide else "❌"
|
||||||
|
|
||||||
|
return (
|
||||||
|
"<table border='1' cellpadding='6' cellspacing='0' style='border-collapse:collapse;'>"
|
||||||
|
"<tr><th>Guía de Remisión</th><th>Valor Referencial SAP</th></tr>"
|
||||||
|
f"{rows}"
|
||||||
|
f"<tr><td><b>Total SAP</b></td><td><b>{total_sap:.2f}</b></td></tr>"
|
||||||
|
f"<tr><td><b>Valor Referencial XML</b></td><td><b>{valor_xml:.2f}</b></td></tr>"
|
||||||
|
f"<tr><td colspan='2'>{icono} {'Coincide' if coincide else 'No coincide'}</td></tr>"
|
||||||
|
"</table>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def build_notification_body(status: str, factura: dict, validation_table: str, valor_referencial_table: str, **kwargs) -> str:
|
||||||
|
"""
|
||||||
|
Construye el cuerpo HTML del email de notificación del resultado
|
||||||
|
del proceso SAP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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``,
|
||||||
|
``numero``, ``razon_social_emisor``).
|
||||||
|
validation_table: Tabla HTML generada por
|
||||||
|
``build_validation_html``.
|
||||||
|
valor_referencial_table: Tabla HTML generada por
|
||||||
|
``build_valor_referencial_html``.
|
||||||
|
**kwargs: Argumentos adicionales:
|
||||||
|
- ``doc_entry`` (int): DocEntry del draft creado.
|
||||||
|
- ``error`` (str): Mensaje de error si ocurrió.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str — Cuerpo HTML completo del email.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Draft creado exitosamente::
|
||||||
|
|
||||||
|
body = build_notification_body(
|
||||||
|
"created", factura, tabla_html, valref_html, doc_entry=2021
|
||||||
|
)
|
||||||
|
# → "<p>✅ Draft SAP B1 creado exitosamente.</p>..."
|
||||||
|
"""
|
||||||
|
base = (
|
||||||
|
f"<p><b>Factura:</b> {factura.get('serie')}-{factura.get('numero')}</p>"
|
||||||
|
f"<p><b>Emisor:</b> {factura.get('razon_social_emisor', '')}</p>"
|
||||||
|
)
|
||||||
|
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:
|
||||||
|
return f"<p>⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.</p>{base}<h3>Validación de precios</h3>{validation_table}{valref_section}"
|
||||||
@@ -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
|
||||||
+37
-9
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
import msal
|
import msal
|
||||||
import requests
|
import requests
|
||||||
import warnings
|
import warnings
|
||||||
@@ -5,6 +6,8 @@ import base64
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -26,7 +29,7 @@ VERIFY_SSL = os.getenv("VERIFY_SSL", "False").lower() == "true"
|
|||||||
def get_access_token() -> str:
|
def get_access_token() -> str:
|
||||||
"""
|
"""
|
||||||
Obtiene un token de acceso de Microsoft Graph usando las credenciales
|
Obtiene un token de acceso de Microsoft Graph usando las credenciales
|
||||||
configuradas en config.json (Client ID, Client Secret y Tenant ID).
|
del .env (CLIENT_ID, CLIENT_SECRET y TENANT_ID).
|
||||||
"""
|
"""
|
||||||
authority = f"https://login.microsoftonline.com/{TENANT_ID}"
|
authority = f"https://login.microsoftonline.com/{TENANT_ID}"
|
||||||
|
|
||||||
@@ -49,11 +52,30 @@ def get_access_token() -> str:
|
|||||||
correlation_id = result.get("correlation_id")
|
correlation_id = result.get("correlation_id")
|
||||||
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})")
|
raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})")
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def es_mensaje_automatico(headers: list[dict] | None) -> bool:
|
||||||
|
"""
|
||||||
|
Indica si un mensaje fue generado automáticamente (respuesta automática,
|
||||||
|
rebote, lista de distribución…) a partir de ``internetMessageHeaders``.
|
||||||
|
|
||||||
|
No se debe responder a estos mensajes: se generarían bucles de correos.
|
||||||
|
"""
|
||||||
|
h = {(x.get("name") or "").lower(): (x.get("value") or "").strip().lower() for x in headers or []}
|
||||||
|
if h.get("auto-submitted", "no") not in ("", "no"):
|
||||||
|
return True
|
||||||
|
if "x-auto-response-suppress" in h or "x-autoreply" in h or "x-autorespond" in h:
|
||||||
|
return True
|
||||||
|
if h.get("precedence") in ("bulk", "junk", "list", "auto_reply"):
|
||||||
|
return True
|
||||||
|
return "report-type=" in h.get("content-type", "") # multipart/report: acuse o rebote de entrega
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
|
def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Obtiene los correos de una cuenta específica en un rango de fechas.
|
Obtiene los correos de una cuenta específica en un rango de fechas.
|
||||||
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject y has_attach.
|
Retorna una lista de JSON con: id, fecha (ajustada -5h), sender, subject, has_attach
|
||||||
|
y es_automatico (True si es una respuesta automática o un rebote).
|
||||||
"""
|
"""
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {token}",
|
"Authorization": f"Bearer {token}",
|
||||||
@@ -70,7 +92,7 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
|
|||||||
|
|
||||||
params = {
|
params = {
|
||||||
"$filter": filter_query,
|
"$filter": filter_query,
|
||||||
"$select": "id,receivedDateTime,hasAttachments,subject,from",
|
"$select": "id,receivedDateTime,hasAttachments,subject,from,internetMessageHeaders",
|
||||||
"$top": 50
|
"$top": 50
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +126,8 @@ def get_emails(user_email: str, token: str, fecha_inicio: str, fecha_fin: str) -
|
|||||||
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
|
"fecha": dt_local.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"sender": sender,
|
"sender": sender,
|
||||||
"subject": msg.get("subject", ""),
|
"subject": msg.get("subject", ""),
|
||||||
"has_attach": msg.get("hasAttachments")
|
"has_attach": msg.get("hasAttachments"),
|
||||||
|
"es_automatico": es_mensaje_automatico(msg.get("internetMessageHeaders")),
|
||||||
})
|
})
|
||||||
|
|
||||||
url = data.get("@odata.nextLink")
|
url = data.get("@odata.nextLink")
|
||||||
@@ -150,7 +173,7 @@ def download_attachments(user_email: str, message_id: str, token: str, download_
|
|||||||
return downloaded_files
|
return downloaded_files
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None) -> bool:
|
def send_email(access_token: str, from_email: str, to_recipients: list[str], subject: str, html_body: str, attachment_paths: list[str] | None = None, cc_recipients: list[str] | None = None, bcc_recipients: list[str] | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF.
|
Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF.
|
||||||
|
|
||||||
@@ -161,6 +184,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
|||||||
:param html_body: Contenido del correo en formato HTML.
|
:param html_body: Contenido del correo en formato HTML.
|
||||||
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
|
:param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán.
|
||||||
:param cc_recipients: Lista de correos electrónicos en copia (CC).
|
:param cc_recipients: Lista de correos electrónicos en copia (CC).
|
||||||
|
:param bcc_recipients: Lista de correos electrónicos en copia oculta (BCC): no los ve el resto de destinatarios.
|
||||||
:return: True si se envió correctamente, False en caso contrario.
|
:return: True si se envió correctamente, False en caso contrario.
|
||||||
"""
|
"""
|
||||||
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
|
url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail"
|
||||||
@@ -189,6 +213,9 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Construir lista de destinatarios en copia oculta (BCC) para Graph API
|
||||||
|
bcc_payload = [{"emailAddress": {"address": email}} for email in (bcc_recipients or [])]
|
||||||
|
|
||||||
# Construir lista de adjuntos
|
# Construir lista de adjuntos
|
||||||
attachments_payload = []
|
attachments_payload = []
|
||||||
if attachment_paths:
|
if attachment_paths:
|
||||||
@@ -214,7 +241,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
|||||||
"contentBytes": encoded_content
|
"contentBytes": encoded_content
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
print(f"Advertencia: El archivo no existe y no se adjuntará: {path}")
|
logger.warning(f"Advertencia: El archivo no existe y no se adjuntará: {path}")
|
||||||
|
|
||||||
# Estructura del mensaje para la API
|
# Estructura del mensaje para la API
|
||||||
email_data = {
|
email_data = {
|
||||||
@@ -226,6 +253,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
|||||||
},
|
},
|
||||||
"toRecipients": recipients_payload,
|
"toRecipients": recipients_payload,
|
||||||
"ccRecipients": cc_payload,
|
"ccRecipients": cc_payload,
|
||||||
|
"bccRecipients": bcc_payload,
|
||||||
"attachments": attachments_payload
|
"attachments": attachments_payload
|
||||||
},
|
},
|
||||||
"saveToSentItems": "true"
|
"saveToSentItems": "true"
|
||||||
@@ -234,13 +262,13 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
|||||||
try:
|
try:
|
||||||
response = requests.post(url, headers=headers, json=email_data, verify=verify_ssl)
|
response = requests.post(url, headers=headers, json=email_data, verify=verify_ssl)
|
||||||
if response.status_code == 202:
|
if response.status_code == 202:
|
||||||
print(f" ├── SUCCESS: Correo enviado exitosamente a: {', '.join(to_recipients)}")
|
logger.info(f" ├── SUCCESS: Correo enviado exitosamente a: {', '.join(to_recipients)}")
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
print(f" ├── ERROR: Al enviar correo: {response.status_code} - {response.text}")
|
logger.error(f" ├── ERROR: Al enviar correo: {response.status_code} - {response.text}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ├── ERROR: Excepcion al enviar correo: {e}")
|
logger.error(f" ├── ERROR: Excepcion al enviar correo: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
"""Persistencia a disco (JSON) de resultados y drafts."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import sunat_schema
|
||||||
|
import pdf_utilities
|
||||||
|
from pipeline_utils import log, ruta_carpeta
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def guardar_resultado(documentos: list[dict], datos_pdf: list[dict], output_folder: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Guarda los resultados XML y PDF en archivos JSON separados.
|
||||||
|
|
||||||
|
El nombre del archivo sigue el patrón
|
||||||
|
``{tipo}_resultado_{serie}-{numero}_{timestamp}.json`` y se escribe en
|
||||||
|
``output_folder``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
documentos: Lista de diccionarios con datos extraídos de XML
|
||||||
|
(producida por ``procesar_xmls``).
|
||||||
|
datos_pdf: Lista de diccionarios con datos extraídos de PDF
|
||||||
|
(producida por ``procesar_pdfs``).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Si ``documentos[0]`` tiene ``serie="F002", numero="486"``::
|
||||||
|
|
||||||
|
guardar_resultado(documentos, pdf_datos, output_folder)
|
||||||
|
# → Crea xml_resultado_F002-486_2026-07-08_12-00-00.json
|
||||||
|
# → Crea pdf_resultado_F002-486_2026-07-08_12-00-00.json
|
||||||
|
"""
|
||||||
|
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")
|
||||||
|
serie = documentos[0].get("serie", "unknown")
|
||||||
|
numero = documentos[0].get("numero", "unknown")
|
||||||
|
base = f"{serie}-{numero}_{ts}"
|
||||||
|
|
||||||
|
saved: list[str] = []
|
||||||
|
|
||||||
|
# XML result
|
||||||
|
xml_name = f"xml_resultado_{base}.json"
|
||||||
|
xml_path = os.path.join(output_folder, xml_name)
|
||||||
|
with open(xml_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(documentos, f, ensure_ascii=False, indent=4)
|
||||||
|
log(f"💾 XML guardado: {xml_name}")
|
||||||
|
saved.append(xml_path)
|
||||||
|
|
||||||
|
# PDF result (normalizado al mismo schema que XML)
|
||||||
|
pdf_docs = []
|
||||||
|
for raw in datos_pdf:
|
||||||
|
if raw.get("error"):
|
||||||
|
continue
|
||||||
|
td = (raw.get("tipo_documento") or "").upper()
|
||||||
|
tipo = pdf_utilities.TIPO_MAP.get(td)
|
||||||
|
if not tipo:
|
||||||
|
continue
|
||||||
|
sn = raw.get("serie_numero") or ""
|
||||||
|
serie_p, num_p = sunat_schema.split_serie_numero(sn)
|
||||||
|
if not serie_p:
|
||||||
|
continue
|
||||||
|
doc = {
|
||||||
|
"ruc_emisor": raw.get("ruc_emisor"),
|
||||||
|
"razon_social_emisor": None, # el PDF no extrae la razón social del emisor
|
||||||
|
"fecha_emision": raw.get("fecha_emision"),
|
||||||
|
"tipo": tipo,
|
||||||
|
"serie": serie_p,
|
||||||
|
"numero": num_p,
|
||||||
|
"ruc_receptor": raw.get("ruc_receptor"),
|
||||||
|
"razon_social_receptor": raw.get("nombre_receptor"),
|
||||||
|
"total_factura": raw.get("monto_total"),
|
||||||
|
"documentos_referencia": [],
|
||||||
|
"tiene_detraccion": False,
|
||||||
|
"codigo_detraccion": None,
|
||||||
|
"porcentaje_detraccion": None,
|
||||||
|
"monto_detraccion": None,
|
||||||
|
"lineas": [],
|
||||||
|
"ruta_pdf": raw.get("archivo"),
|
||||||
|
}
|
||||||
|
det = raw.get("detraccion") or {}
|
||||||
|
if det.get("tiene"):
|
||||||
|
doc["tiene_detraccion"] = True
|
||||||
|
doc["porcentaje_detraccion"] = det.get("porcentaje")
|
||||||
|
doc["monto_detraccion"] = det.get("monto")
|
||||||
|
for item in raw.get("items") or []:
|
||||||
|
linea = {
|
||||||
|
"descripcion": item.get("descripcion", ""),
|
||||||
|
"cantidad": item.get("cantidad"),
|
||||||
|
"precio_unitario": item.get("precio_unitario"),
|
||||||
|
"total_linea": item.get("total"),
|
||||||
|
"guias_remision": [],
|
||||||
|
"guia_transportista": None,
|
||||||
|
}
|
||||||
|
for g in item.get("guias_remision") or []:
|
||||||
|
if isinstance(g, str):
|
||||||
|
m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", g)
|
||||||
|
if m:
|
||||||
|
linea["guias_remision"].append({
|
||||||
|
"tipo": "09",
|
||||||
|
"serie": m.group(1).upper(),
|
||||||
|
"numero": m.group(2).lstrip("0") or "0",
|
||||||
|
})
|
||||||
|
elif isinstance(g, dict):
|
||||||
|
linea["guias_remision"].append(dict(g))
|
||||||
|
gt = item.get("guia_transportista")
|
||||||
|
if gt:
|
||||||
|
if isinstance(gt, str):
|
||||||
|
m = re.search(r"\b([TVE][A-Z0-9]{3,4})\s*-?\s*(\d+)", gt)
|
||||||
|
if m:
|
||||||
|
linea["guia_transportista"] = {
|
||||||
|
"tipo": "31",
|
||||||
|
"serie": m.group(1).upper(),
|
||||||
|
"numero": m.group(2).lstrip("0") or "0",
|
||||||
|
}
|
||||||
|
elif isinstance(gt, dict):
|
||||||
|
linea["guia_transportista"] = dict(gt)
|
||||||
|
doc["lineas"].append(linea)
|
||||||
|
pdf_docs.append(doc)
|
||||||
|
|
||||||
|
if pdf_docs:
|
||||||
|
pdf_name = f"pdf_resultado_{base}.json"
|
||||||
|
pdf_path = os.path.join(output_folder, pdf_name)
|
||||||
|
with open(pdf_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(pdf_docs, f, ensure_ascii=False, indent=4)
|
||||||
|
log(f"💾 PDF guardado: {pdf_name}")
|
||||||
|
saved.append(pdf_path)
|
||||||
|
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def guardar_draft_json(draft: dict, serie: str, numero: str, output_folder: str) -> None:
|
||||||
|
"""
|
||||||
|
Guarda el payload del draft SAP B1 en un archivo JSON para
|
||||||
|
depuración.
|
||||||
|
|
||||||
|
El nombre sigue el patrón
|
||||||
|
``draft_sapb1_{serie}-{numero}_{timestamp}.json``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draft: Payload del draft SAP B1 (producido por
|
||||||
|
``build_sap_draft``).
|
||||||
|
serie: Serie de la factura (ej. ``"F002"``).
|
||||||
|
numero: Número de la factura (ej. ``"486"``).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Draft de factura F002-486::
|
||||||
|
|
||||||
|
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")
|
||||||
|
filename = f"draft_sapb1_{serie}-{numero}_{ts}.json"
|
||||||
|
with open(os.path.join(output_folder, filename), "w", encoding="utf-8") as f:
|
||||||
|
json.dump(draft, f, ensure_ascii=False, indent=4)
|
||||||
|
log(f"💾 Draft guardado: {filename}")
|
||||||
+274
-1169
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,54 @@
|
|||||||
|
"""Utilidades compartidas por los módulos del pipeline."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
logger = logging.getLogger("pipeline")
|
||||||
|
|
||||||
|
|
||||||
|
def ruta_carpeta(subcarpeta: str) -> str:
|
||||||
|
"""Retorna la ruta absoluta de ``subcarpeta`` relativa a la raíz del proyecto."""
|
||||||
|
return os.path.join(os.path.dirname(__file__), subcarpeta)
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg: str, level: int = 1, last: bool = False, severity: int = logging.INFO) -> None:
|
||||||
|
"""
|
||||||
|
Registra ``msg`` con la indentación de árbol usada en el log del pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: Mensaje a registrar.
|
||||||
|
level: Nivel de indentación del árbol (1-4).
|
||||||
|
last: True si es el último hijo de su rama (``└──`` en vez de ``├──``).
|
||||||
|
severity: Nivel de ``logging`` (``logging.WARNING``, ``logging.ERROR``…).
|
||||||
|
Por defecto ``logging.INFO``.
|
||||||
|
"""
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# Diferencia máxima, en soles, entre el importe de la factura y el de SAP para darlos por iguales.
|
||||||
|
TOLERANCIA_IMPORTE = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def importes_coinciden(a: float, b: float) -> bool:
|
||||||
|
"""
|
||||||
|
True si dos importes son iguales **a nivel de céntimos**, que es lo que muestra un comprobante.
|
||||||
|
|
||||||
|
La diferencia se redondea a 2 decimales y se admite hasta ``TOLERANCIA_IMPORTE`` (1 céntimo).
|
||||||
|
Comparar la diferencia sin redondear (``abs(a - b) < 0.01``) no es fiable: con coma flotante,
|
||||||
|
431.41 - 431.40 da 0.01000000000004775 (se rechazaba) y 624.39 - 624.38 da 0.00999999999999091
|
||||||
|
(se aceptaba), de modo que el mismo céntimo pasaba o no según el importe.
|
||||||
|
"""
|
||||||
|
return round(abs(a - b), 2) <= TOLERANCIA_IMPORTE
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}} ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏ </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;"> </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;">✓ </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">✓ </span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">✕ </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>
|
||||||
@@ -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}} ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏ </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;"> </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;">● </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>
|
||||||
@@ -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}} ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏  ͏ </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;"> </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;">✕ </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">✓ </span>Conforme</span>{{/conforme}}{{^conforme}}<span style="color:#C0291C;"><span aria-hidden="true">✕ </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
@@ -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)
|
||||||
+33
-13
@@ -1,27 +1,47 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=64"]
|
requires = ["setuptools>=64"]
|
||||||
build-backend = "setuptools.backends._legacy:_Backend"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "descarga_factura_transporte"
|
name = "descarga_factura_transporte"
|
||||||
version = "1.1.0"
|
version = "1.2.1"
|
||||||
description = "Descarga y procesamiento de facturas de transporte desde Office365"
|
description = "Descarga y procesamiento de facturas de transporte desde Office365"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"certifi==2026.6.17",
|
"hdbcli==2.29.25",
|
||||||
"cffi==2.1.0",
|
|
||||||
"charset-normalizer==3.4.9",
|
|
||||||
"cryptography==49.0.0",
|
|
||||||
"dotenv==0.9.9",
|
|
||||||
"idna==3.18",
|
|
||||||
"msal==1.37.0",
|
"msal==1.37.0",
|
||||||
"pdfminer.six==20260107",
|
|
||||||
"pdfplumber==0.11.10",
|
"pdfplumber==0.11.10",
|
||||||
"pillow==12.3.0",
|
|
||||||
"pycparser==3.0",
|
|
||||||
"PyJWT==2.13.0",
|
|
||||||
"pypdfium2==5.11.0",
|
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"urllib3==2.7.0",
|
"urllib3==2.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest==9.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = [
|
||||||
|
"adjuntos",
|
||||||
|
"config_validator",
|
||||||
|
"database_utilities",
|
||||||
|
"documentos",
|
||||||
|
"draft_sap",
|
||||||
|
"email_utilities",
|
||||||
|
"hana_manager",
|
||||||
|
"logger_manager",
|
||||||
|
"main",
|
||||||
|
"notificaciones",
|
||||||
|
"outlook_utilities",
|
||||||
|
"pdf_utilities",
|
||||||
|
"persistencia",
|
||||||
|
"pipeline",
|
||||||
|
"pipeline_utils",
|
||||||
|
"sapb1_manager",
|
||||||
|
"sunat_schema",
|
||||||
|
]
|
||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
hdbcli
|
hdbcli==2.29.25
|
||||||
msal
|
msal==1.37.0
|
||||||
pdfplumber
|
pdfplumber==0.11.10
|
||||||
python-dotenv
|
python-dotenv==1.2.2
|
||||||
requests
|
requests==2.34.2
|
||||||
urllib3
|
urllib3==2.7.0
|
||||||
@@ -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)
|
||||||
+109
-38
@@ -1,4 +1,5 @@
|
|||||||
|
|
||||||
|
import logging
|
||||||
import requests
|
import requests
|
||||||
import urllib3
|
import urllib3
|
||||||
import os
|
import os
|
||||||
@@ -7,6 +8,8 @@ from typing import Optional, Dict, Any
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import hana_manager
|
import hana_manager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CONFIGURACIÓN
|
# CONFIGURACIÓN
|
||||||
@@ -28,6 +31,15 @@ SAPB1_VERIFY_SSL = os.getenv("SAP_B1_VERIFY_SSL", "False").lower() == "true"
|
|||||||
_sap_session: dict = {"headers": None, "b1session": None, "routeid": None}
|
_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
|
# LOGIN
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -37,17 +49,19 @@ def login() -> dict:
|
|||||||
"UserName": SAPB1_USER,
|
"UserName": SAPB1_USER,
|
||||||
"Password": SAPB1_PASSWORD,
|
"Password": SAPB1_PASSWORD,
|
||||||
}
|
}
|
||||||
print("🔐 Iniciando sesión en SAP B1 Service Layer...")
|
logger.info("🔐 Iniciando sesión en SAP B1 Service Layer...")
|
||||||
resp = requests.post(
|
try:
|
||||||
f"{SAPB1_URL}/Login",
|
resp = requests.post(
|
||||||
json=payload,
|
f"{SAPB1_URL}/Login",
|
||||||
timeout=SAPB1_TIMEOUT,
|
json=payload,
|
||||||
verify=SAPB1_VERIFY_SSL,
|
timeout=SAPB1_TIMEOUT,
|
||||||
)
|
verify=SAPB1_VERIFY_SSL,
|
||||||
if resp.status_code != 200:
|
|
||||||
raise ConnectionError(
|
|
||||||
f"Login fallido: HTTP {resp.status_code} - {resp.text}"
|
|
||||||
)
|
)
|
||||||
|
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()
|
data = resp.json()
|
||||||
b1session = data["SessionId"]
|
b1session = data["SessionId"]
|
||||||
@@ -55,7 +69,7 @@ def login() -> dict:
|
|||||||
|
|
||||||
headers = {"Cookie": f"B1SESSION={b1session}; ROUTEID={routeid}"}
|
headers = {"Cookie": f"B1SESSION={b1session}; ROUTEID={routeid}"}
|
||||||
_sap_session.update({"headers": headers, "b1session": b1session, "routeid": routeid})
|
_sap_session.update({"headers": headers, "b1session": b1session, "routeid": routeid})
|
||||||
print(f"✅ Sesión SAP B1 establecida (B1SESSION: {b1session[:10]}...)")
|
logger.info(f"✅ Sesión SAP B1 establecida (B1SESSION: {b1session[:10]}...)")
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
@@ -64,9 +78,9 @@ def login() -> dict:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
def logout() -> None:
|
def logout() -> None:
|
||||||
if not _sap_session["b1session"]:
|
if not _sap_session["b1session"]:
|
||||||
print("⚠️ No hay sesión activa para cerrar.")
|
logger.warning("⚠️ No hay sesión activa para cerrar.")
|
||||||
return
|
return
|
||||||
print("🔓 Cerrando sesión SAP B1...")
|
logger.info("🔓 Cerrando sesión SAP B1...")
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
f"{SAPB1_URL}/Logout",
|
f"{SAPB1_URL}/Logout",
|
||||||
@@ -75,11 +89,11 @@ def logout() -> None:
|
|||||||
verify=SAPB1_VERIFY_SSL,
|
verify=SAPB1_VERIFY_SSL,
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
print("✅ Sesión SAP B1 cerrada correctamente.")
|
logger.info("✅ Sesión SAP B1 cerrada correctamente.")
|
||||||
else:
|
else:
|
||||||
print(f"⚠️ Logout respondió HTTP {resp.status_code}")
|
logger.warning(f"⚠️ Logout respondió HTTP {resp.status_code}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"❌ Error al cerrar sesión: {exc}")
|
logger.error(f"❌ Error al cerrar sesión: {exc}")
|
||||||
finally:
|
finally:
|
||||||
_sap_session.update({"headers": None, "b1session": None, "routeid": None})
|
_sap_session.update({"headers": None, "b1session": None, "routeid": None})
|
||||||
|
|
||||||
@@ -102,9 +116,9 @@ def _log_response(method: str, endpoint: str, response: requests.Response, paylo
|
|||||||
f" ({len(response.content)} bytes)"
|
f" ({len(response.content)} bytes)"
|
||||||
)
|
)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
print(f"⚠️ {log_line} - {response.text[:300]}")
|
logger.warning(f"⚠️ {log_line} - {response.text[:300]}")
|
||||||
else:
|
else:
|
||||||
print(log_line)
|
logger.info(log_line)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -133,7 +147,7 @@ def forward_to_sapb1(
|
|||||||
if extra_headers:
|
if extra_headers:
|
||||||
req_headers.update(extra_headers)
|
req_headers.update(extra_headers)
|
||||||
|
|
||||||
print(f" ├── 🔄 [{method}] {endpoint_path}")
|
logger.info(f" ├── 🔄 [{method}] {endpoint_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = requests.request(
|
resp = requests.request(
|
||||||
@@ -146,14 +160,14 @@ def forward_to_sapb1(
|
|||||||
verify=SAPB1_VERIFY_SSL,
|
verify=SAPB1_VERIFY_SSL,
|
||||||
)
|
)
|
||||||
except requests.exceptions.Timeout:
|
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:
|
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:
|
except requests.RequestException as exc:
|
||||||
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
|
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
|
||||||
|
|
||||||
if resp.status_code == 401:
|
if resp.status_code == 401:
|
||||||
print("⚠️ Sesión expirada (401). Reautenticando e intentando una vez...")
|
logger.warning("⚠️ Sesión expirada (401). Reautenticando e intentando una vez...")
|
||||||
req_headers["Cookie"] = login()["Cookie"]
|
req_headers["Cookie"] = login()["Cookie"]
|
||||||
resp = requests.request(
|
resp = requests.request(
|
||||||
method=method,
|
method=method,
|
||||||
@@ -177,7 +191,8 @@ def forward_to_sapb1(
|
|||||||
)
|
)
|
||||||
except (json.JSONDecodeError, AttributeError):
|
except (json.JSONDecodeError, AttributeError):
|
||||||
sap_message = resp.text
|
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"Error en SAP {method} {endpoint_path}: "
|
||||||
f"HTTP {resp.status_code} - {sap_message}"
|
f"HTTP {resp.status_code} - {sap_message}"
|
||||||
)
|
)
|
||||||
@@ -191,17 +206,61 @@ def forward_to_sapb1(
|
|||||||
def create_draft(payload: dict) -> dict:
|
def create_draft(payload: dict) -> dict:
|
||||||
resp = forward_to_sapb1("POST", "Drafts", json_data=payload)
|
resp = forward_to_sapb1("POST", "Drafts", json_data=payload)
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
print(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}")
|
logger.info(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}")
|
||||||
return result
|
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))
|
# OBTENER FACTURA DE COMPRA (GET /PurchaseInvoices(DocEntry))
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
def get_purchase_invoice(doc_entry: int) -> dict:
|
def get_purchase_invoice(doc_entry: int) -> dict:
|
||||||
resp = forward_to_sapb1("GET", f"PurchaseInvoices({doc_entry})")
|
resp = forward_to_sapb1("GET", f"PurchaseInvoices({doc_entry})")
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
print(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida")
|
logger.info(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -242,6 +301,10 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
|
|||||||
Returns:
|
Returns:
|
||||||
list[dict] — Documentos completos, o [] si no se encontró nada en
|
list[dict] — Documentos completos, o [] si no se encontró nada en
|
||||||
ninguna fuente.
|
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}"
|
codigo = f"{tipo}{serie}-{numero}"
|
||||||
|
|
||||||
@@ -249,7 +312,7 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
|
|||||||
entries = _get_delivery_entries(tipo, serie, numero)
|
entries = _get_delivery_entries(tipo, serie, numero)
|
||||||
|
|
||||||
if not entries:
|
if not entries:
|
||||||
print(f" │ ⚠️ Guía {codigo}: sin registros en SAP (SL + HANA)")
|
logger.warning(f" │ ⚠️ Guía {codigo}: sin registros en SAP (SL + HANA)")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# ── Fase 2: Obtener documento completo por cada DocEntry ─────────────
|
# ── Fase 2: Obtener documento completo por cada DocEntry ─────────────
|
||||||
@@ -260,20 +323,22 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
|
|||||||
try:
|
try:
|
||||||
resp = forward_to_sapb1("GET", f"{entidad}({doc_entry})")
|
resp = forward_to_sapb1("GET", f"{entidad}({doc_entry})")
|
||||||
doc = resp.json()
|
doc = resp.json()
|
||||||
print(f" │ └─ {entidad}({doc_entry}) obtenido")
|
logger.info(f" │ └─ {entidad}({doc_entry}) obtenido")
|
||||||
|
|
||||||
if entidad == "StockTransfers":
|
if entidad == "StockTransfers":
|
||||||
num_ov = doc.get("U_IDC_NUMOV")
|
num_ov = doc.get("U_IDC_NUMOV")
|
||||||
if not num_ov:
|
if not num_ov:
|
||||||
print(f" │ ⚠️ StockTransfers({doc_entry}) sin U_IDC_NUMOV: no se puede validar precios")
|
logger.warning(f" │ ⚠️ StockTransfers({doc_entry}) sin U_IDC_NUMOV: no se puede validar precios")
|
||||||
continue
|
continue
|
||||||
resp_ov = forward_to_sapb1("GET", f"Orders({num_ov})")
|
resp_ov = forward_to_sapb1("GET", f"Orders({num_ov})")
|
||||||
doc = resp_ov.json()
|
doc = resp_ov.json()
|
||||||
print(f" │ └─ Orders({num_ov}) obtenido (Orden de Venta de StockTransfers({doc_entry}))")
|
logger.info(f" │ └─ Orders({num_ov}) obtenido (Orden de Venta de StockTransfers({doc_entry}))")
|
||||||
|
|
||||||
docs.append(doc)
|
docs.append(doc)
|
||||||
|
except SapNoDisponible:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" │ ⚠️ Error obteniendo {entidad}({doc_entry}): {e}")
|
logger.warning(f" │ ⚠️ Error obteniendo {entidad}({doc_entry}): {e}")
|
||||||
|
|
||||||
return docs
|
return docs
|
||||||
|
|
||||||
@@ -281,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:
|
def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
|
||||||
"""Fase 1: intenta SL, fallback a HANA. Retorna [{DocEntry, DocNum, CardCode, CardName, Entidad}, ...]."""
|
"""Fase 1: intenta SL, fallback a HANA. Retorna [{DocEntry, DocNum, CardCode, CardName, Entidad}, ...]."""
|
||||||
codigo = f"{tipo}{serie}-{numero}"
|
codigo = f"{tipo}{serie}-{numero}"
|
||||||
|
service_layer_caido = None
|
||||||
|
|
||||||
# ── Intentar Service Layer ───────────────────────────────────────────
|
# ── Intentar Service Layer ───────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
@@ -302,13 +368,15 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
|
|||||||
if sl_entries:
|
if sl_entries:
|
||||||
for sl_entry in sl_entries:
|
for sl_entry in sl_entries:
|
||||||
sl_entry["Entidad"] = "DeliveryNotes"
|
sl_entry["Entidad"] = "DeliveryNotes"
|
||||||
print(f" │ 🔍 Guía {codigo}: SL → {len(sl_entries)} DocEntry encontrados")
|
logger.info(f" │ 🔍 Guía {codigo}: SL → {len(sl_entries)} DocEntry encontrados")
|
||||||
return sl_entries
|
return sl_entries
|
||||||
|
|
||||||
print(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...")
|
logger.info(f" │ 🔍 Guía {codigo}: SL → 0 resultados. Consultando HANA...")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...")
|
if isinstance(e, SapNoDisponible):
|
||||||
|
service_layer_caido = e
|
||||||
|
logger.warning(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...")
|
||||||
|
|
||||||
# ── Fallback a HANA ──────────────────────────────────────────────────
|
# ── Fallback a HANA ──────────────────────────────────────────────────
|
||||||
try:
|
try:
|
||||||
@@ -318,12 +386,15 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
|
|||||||
hana_entry["Entidad"] = _TABLA_A_ENTIDAD_SL.get(
|
hana_entry["Entidad"] = _TABLA_A_ENTIDAD_SL.get(
|
||||||
hana_entry.get("Tabla"), "DeliveryNotes"
|
hana_entry.get("Tabla"), "DeliveryNotes"
|
||||||
)
|
)
|
||||||
print(f" │ 🔍 Guía {codigo}: HANA → {len(hana_entries)} DocEntry encontrados")
|
logger.info(f" │ 🔍 Guía {codigo}: HANA → {len(hana_entries)} DocEntry encontrados")
|
||||||
return hana_entries
|
return hana_entries
|
||||||
print(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
|
logger.info(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" │ ⚠️ Guía {codigo}: HANA falló ({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 []
|
return []
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -361,7 +432,7 @@ def obtener_condicion_pago(card_code: str) -> dict | None:
|
|||||||
"NumberOfAdditionalDays": terms.get("NumberOfAdditionalDays") or 0,
|
"NumberOfAdditionalDays": terms.get("NumberOfAdditionalDays") or 0,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" │ ⚠️ Error obteniendo condición de pago de {card_code}: {e}")
|
logger.warning(f" │ ⚠️ Error obteniendo condición de pago de {card_code}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -394,5 +465,5 @@ def get_zonas() -> list:
|
|||||||
)
|
)
|
||||||
result = resp.json()
|
result = resp.json()
|
||||||
value = result.get("value", [])
|
value = result.get("value", [])
|
||||||
print(f" ├── ✅ U_IDC_ZONAS obtenida ({len(value)} registros)")
|
logger.info(f" ├── ✅ U_IDC_ZONAS obtenida ({len(value)} registros)")
|
||||||
return value
|
return value
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
Registro de comprobantes y códigos de seguimiento.
|
||||||
|
|
||||||
|
Cada comprobante que llega por correo recibe un ``codigo_seguimiento`` (por
|
||||||
|
ejemplo ``ABR-261019-00042``) que se comunica al proveedor y que sirve para
|
||||||
|
identificarlo si lo reenvía. El registro también guarda el resultado del
|
||||||
|
proceso (aceptado, rechazado o error) y el estado de la notificación enviada.
|
||||||
|
|
||||||
|
Un comprobante se identifica por (RUC emisor, tipo, serie, número): si vuelve
|
||||||
|
a llegar, en el mismo correo o en otro, conserva su código. Los correos que no
|
||||||
|
traen ningún comprobante identificable (sin adjuntos, sin XML válido…) se
|
||||||
|
registran aparte con ``registrar_correo``: una fila por correo, sin serie ni
|
||||||
|
número.
|
||||||
|
|
||||||
|
El esquema se crea en ``database_utilities.init_db``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import database_utilities
|
||||||
|
|
||||||
|
PREFIJO_CODIGO = "ABR"
|
||||||
|
|
||||||
|
ESTADOS = ("pendiente", "aceptado", "rechazado", "error")
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
@contextmanager
|
||||||
|
def _conexion():
|
||||||
|
"""Conexión con filas tipo diccionario; confirma al salir o revierte si hubo error."""
|
||||||
|
conn = sqlite3.connect(database_utilities.DB_NAME)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _ahora() -> str:
|
||||||
|
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
|
||||||
|
def _a_dict(fila: sqlite3.Row | None) -> dict | None:
|
||||||
|
if fila is None:
|
||||||
|
return None
|
||||||
|
registro = dict(fila)
|
||||||
|
if registro.get("observaciones") is not None:
|
||||||
|
registro["observaciones"] = json.loads(registro["observaciones"])
|
||||||
|
return registro
|
||||||
|
|
||||||
|
|
||||||
|
def _insertar(conn: sqlite3.Connection, email_id: str, ruc_emisor, tipo, serie, numero) -> dict:
|
||||||
|
"""Inserta la fila y le asigna el código, que se deriva de su ``id`` (único por construcción)."""
|
||||||
|
ahora = _ahora()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""INSERT INTO comprobantes (email_id, ruc_emisor, tipo, serie, numero, creado_en, actualizado_en)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(email_id, ruc_emisor, tipo, serie, numero, ahora, ahora),
|
||||||
|
)
|
||||||
|
codigo = f"{PREFIJO_CODIGO}-{datetime.now():%y%m%d}-{cursor.lastrowid:05d}"
|
||||||
|
conn.execute("UPDATE comprobantes SET codigo_seguimiento = ? WHERE id = ?", (codigo, cursor.lastrowid))
|
||||||
|
return _a_dict(conn.execute("SELECT * FROM comprobantes WHERE id = ?", (cursor.lastrowid,)).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
def registrar_comprobante(email_id: str, ruc_emisor: str, tipo: str, serie: str, numero: str) -> dict:
|
||||||
|
"""
|
||||||
|
Registra un comprobante, o recupera el registro existente si ya había llegado.
|
||||||
|
|
||||||
|
Si el comprobante ya existía y llega en un correo distinto, se conserva su
|
||||||
|
código, se actualiza ``email_id`` al correo más reciente y se incrementa
|
||||||
|
``veces_recibido``. Si es el mismo correo (reproceso), no cambia nada.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — La fila de ``comprobantes`` (con ``codigo_seguimiento``) más la
|
||||||
|
clave ``reenvio``: True si el comprobante ya había llegado en otro correo.
|
||||||
|
"""
|
||||||
|
with _conexion() as conn:
|
||||||
|
fila = conn.execute(
|
||||||
|
"SELECT * FROM comprobantes WHERE ruc_emisor = ? AND tipo = ? AND serie = ? AND numero = ?",
|
||||||
|
(ruc_emisor, tipo, serie, numero),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if fila is None:
|
||||||
|
registro = _insertar(conn, email_id, ruc_emisor, tipo, serie, numero)
|
||||||
|
registro["reenvio"] = False
|
||||||
|
return registro
|
||||||
|
|
||||||
|
reenvio = fila["email_id"] != email_id
|
||||||
|
if reenvio:
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE comprobantes
|
||||||
|
SET email_id = ?, veces_recibido = veces_recibido + 1, actualizado_en = ?
|
||||||
|
WHERE id = ?""",
|
||||||
|
(email_id, _ahora(), fila["id"]),
|
||||||
|
)
|
||||||
|
registro = _a_dict(conn.execute("SELECT * FROM comprobantes WHERE id = ?", (fila["id"],)).fetchone())
|
||||||
|
registro["reenvio"] = reenvio
|
||||||
|
return registro
|
||||||
|
|
||||||
|
|
||||||
|
def registrar_correo(email_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Registra un correo que no trae ningún comprobante identificable (una fila
|
||||||
|
por correo, sin RUC, serie ni número) y devuelve su registro. Idempotente.
|
||||||
|
"""
|
||||||
|
with _conexion() as conn:
|
||||||
|
fila = conn.execute(
|
||||||
|
"SELECT * FROM comprobantes WHERE email_id = ? AND serie IS NULL", (email_id,)
|
||||||
|
).fetchone()
|
||||||
|
if fila is not None:
|
||||||
|
return _a_dict(fila)
|
||||||
|
return _insertar(conn, email_id, None, None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def obtener(codigo_seguimiento: str) -> dict | None:
|
||||||
|
"""Retorna el registro con ese código, o None si no existe."""
|
||||||
|
with _conexion() as conn:
|
||||||
|
return _a_dict(conn.execute(
|
||||||
|
"SELECT * FROM comprobantes WHERE codigo_seguimiento = ?", (codigo_seguimiento,)
|
||||||
|
).fetchone())
|
||||||
|
|
||||||
|
|
||||||
|
def actualizar_resultado(codigo_seguimiento: str, estado: str, observaciones: list | None = None,
|
||||||
|
doc_entry: int | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Guarda el resultado del proceso de un comprobante.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
codigo_seguimiento: Código devuelto por ``registrar_comprobante``/``registrar_correo``.
|
||||||
|
estado: Uno de ``ESTADOS``.
|
||||||
|
observaciones: Lista serializable a JSON (motivos de rechazo o detalle del error).
|
||||||
|
doc_entry: DocEntry del draft creado en SAP, si corresponde.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si ``estado`` no es válido.
|
||||||
|
LookupError: Si no existe el código.
|
||||||
|
"""
|
||||||
|
if estado not in ESTADOS:
|
||||||
|
raise ValueError(f"Estado inválido: {estado!r} (válidos: {', '.join(ESTADOS)})")
|
||||||
|
|
||||||
|
with _conexion() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""UPDATE comprobantes
|
||||||
|
SET estado = ?, observaciones = ?, doc_entry = ?, actualizado_en = ?
|
||||||
|
WHERE codigo_seguimiento = ?""",
|
||||||
|
(estado, None if observaciones is None else json.dumps(observaciones, ensure_ascii=False),
|
||||||
|
doc_entry, _ahora(), codigo_seguimiento),
|
||||||
|
)
|
||||||
|
if cursor.rowcount == 0:
|
||||||
|
raise LookupError(f"No existe el código de seguimiento {codigo_seguimiento}")
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
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.
|
||||||
|
LookupError: Si no existe el código.
|
||||||
|
"""
|
||||||
|
if estado not in ESTADOS_NOTIFICACION:
|
||||||
|
raise ValueError(f"Estado de notificación inválido: {estado!r} (válidos: {', '.join(ESTADOS_NOTIFICACION)})")
|
||||||
|
|
||||||
|
ahora = _ahora()
|
||||||
|
with _conexion() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""UPDATE comprobantes
|
||||||
|
SET notificacion_estado = ?, notificacion_error = ?, notificacion_fecha = ?,
|
||||||
|
notificacion_destinatario = ?, actualizado_en = ?
|
||||||
|
WHERE 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]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import database_utilities
|
||||||
|
|
||||||
|
RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
FIXTURES = os.path.join(RAIZ, "tests", "fixtures", "correos_prueba")
|
||||||
|
GOLDEN = os.path.join(RAIZ, "tests", "golden")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def actualizar_golden() -> bool:
|
||||||
|
"""True si se ejecutó con ``UPDATE_GOLDEN=1`` (regenera los resultados esperados)."""
|
||||||
|
return os.environ.get("UPDATE_GOLDEN") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def base_de_datos_temporal(tmp_path_factory, monkeypatch):
|
||||||
|
"""
|
||||||
|
Toda prueba usa una BD SQLite propia: ``DB_NAME`` es relativo al directorio
|
||||||
|
de trabajo, así que sin esto se escribiría en el ``emails.db`` real.
|
||||||
|
"""
|
||||||
|
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")
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 312 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user