se agrega la rotación de log y se actualiza la documentación del proyecto

This commit is contained in:
George committed 2026-08-05 08:30:16 -05:00
1 parent cf2c1ee147
commit 372e6fe7f8
13 files changed
+459 -97

No files matched your search

+9 -6
View File
@@ -1,12 +1,15 @@
.git
tests/
__pycache__
*.pyc
*.log
.env
correos-venv/
__pycache__/
output_json/
logs/
adjuntos/
resultado/
data/
.git/
.gitignore
*.pyc
*.log
.env
.DS_Store
.pytest_cache/
+1
View File
@@ -6,3 +6,4 @@ output_json/
resultado/
.DS_Store
emails.db
.pytest_cache/
+6 -3
View File
@@ -1,9 +1,12 @@
FROM python:3.11-slim
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
TZ=America/Lima
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --upgrade pip --no-cache-dir && pip install --no-cache-dir -r requirements.txt
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -11,6 +14,6 @@ RUN apt-get update \
rsync \
&& rm -rf /var/lib/apt/lists/*
COPY *.py ./
COPY . .
CMD ["python", "main.py"]
+1 -1
View File
@@ -1,6 +1,6 @@
import sqlite3
DB_NAME = "emails.db"
DB_NAME = "data/emails.db"
# ========================================================================
def init_db():
+1 -2
View File
@@ -3,7 +3,6 @@ services:
build: .
container_name: descarga_adjuntos_cpe
restart: unless-stopped
network_mode: host
environment:
- TZ=America/Lima
volumes:
@@ -14,4 +13,4 @@ services:
- ./output_json:/app/output_json
- ./adjuntos:/app/adjuntos
- ./resultado:/app/resultado
- /mnt/FileSAP:/mnt/FileSAP
- /Users/george/FileSAP:/mnt/FileSAP
+118
View File
@@ -0,0 +1,118 @@
# Descarga Adjuntos Correos CPE
Daemon que descarga automáticamente adjuntos de correos electrónicos (facturas, boletas, notas de crédito) desde Microsoft Outlook, extrae metadatos de archivos XML/PDF, y los sincroniza a una carpeta compartida SMB.
## Arquitectura
![Diagrama de Flujo](flujo.md)
## Requisitos
- Docker y Docker Compose
- Python 3.12 (solo para ejecución local sin Docker)
- Acceso a Microsoft Graph API (credenciales de Azure AD)
- Servidor SMB/CIFS en la red local
## Instalación
### Con Docker (recomendado)
```bash
# Clonar el repositorio
git clone <url-del-repositorio>
cd descarga_adjuntos_correos_cpe
# Crear los directorios necesarios
mkdir -p logs data adjuntos resultado output_json
# Configurar archivos (ver configuracion.md)
# Editar config.json y scheduler.json
# Construir y ejecutar
docker compose build
docker compose up -d
```
### Sin Docker
```bash
# Crear entorno virtual
python -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
# Instalar dependencias
pip install -r requirements.txt
# Ejecutar
python main.py
```
## Estructura del Proyecto
```
├── main.py # Punto de entrada - daemon principal
├── config.json # Configuracion de credenciales y rutas
├── config_loader.py # Carga de config.json en dataclasses
├── config_model.py # Definicion del esquema de configuracion
├── scheduler.json # Horarios y dias de operacion
├── outlook_utilities.py # Autenticacion MSAL y descarga de correos
├── email_services.py # Filtrado de correos y descompresion ZIP
├── document_processor.py # Extraccion de metadata de XML
├── pdf_utilities.py # Extraccion de texto y regex de PDFs
├── llm_processor.py # Fallback con LLM (Ollama) para PDFs
├── file_manager.py # Renombrado, movimiento y limpieza de archivos
├── database_utilities.py # SQLite para tracking de correos procesados
├── smb_manager.py # Montaje y sincronizacion SMB
├── logger_utilities.py # Sistema de logging con rotacion diaria
├── Dockerfile # Imagen Docker
├── docker-compose.yml # Orquestacion del servicio
├── logs/ # Archivos de log diarios
├── data/ # Base de datos SQLite
├── adjuntos/ # Descarga temporal de adjuntos
├── resultado/ # Archivos procesados y renombrados
├── output_json/ # Exportacion JSON de resultados
└── tests/ # Suite de pruebas
```
## Comandos Utiles
```bash
# Ver logs en tiempo real
docker compose logs -f descarga-cpe
# Ver ultimas lineas de log
docker compose logs --tail=50 descarga-cpe
# Detener el servicio
docker compose stop descarga-cpe
# Reiniciar el servicio
docker compose restart descarga-cpe
# Reconstruir tras cambios en el codigo
docker compose build descarga-cpe && docker compose up -d descarga-cpe
# Verificar estado del contenedor
docker compose ps descarga-cpe
# Ejecutar pruebas
python -m pytest tests/ -v
```
## Scheduler
El daemon opera segun los horarios definidos en `scheduler.json`:
| Horario | Intervalo | Modo |
|---------|-----------|------|
| 06:30 - 13:15 | 5 minutos | rapido |
| 13:15 - 14:00 | 1 hora | full |
| 14:00 - 17:30 | 5 minutos | rapido |
| 17:30 - 21:30 | 1 hora | full |
Dias activos: Lunes a Viernes (0-4)
## Logs
Los archivos de log se guardan en `logs/` con formato `YYYY-MM-DD.log`. La rotacion es automatica al cambiar de dia. Los logs con mas de 30 dias se eliminan automaticamente al iniciar el daemon.
+192
View File
@@ -0,0 +1,192 @@
# Guia de Configuracion
## config.json
Archivo de configuracion principal con credenciales y rutas. **No commitear este archivo al repositorio.**
### Estructura completa
```json
{
"hana": {
"server": "IP_DEL_SERVIDOR",
"port": 30015,
"user": "USUARIO_HANA",
"password": "CONTRASENA_HANA"
},
"outlook": {
"client_id": "CLIENT_ID_AZURE_AD",
"client_secret": "CLIENT_SECRET_AZURE_AD",
"tenant_id": "TENANT_ID_AZURE_AD",
"download_folder": "./adjuntos",
"verify_ssl": false,
"ca_bundle": null
},
"smb": {
"server": "IP_SERVIDOR_SMB",
"username": "USUARIO_SMB",
"password": "CONTRASENA_SMB",
"share": "NOMBRE_DEL_SHARE",
"mount_points": {
"windows": "S:",
"linux": "/mnt/FileSAP",
"mac": "/Users/usuario/FileSAP",
"docker": "/mnt/FileSAP"
}
},
"mails": [
{
"email": "correo@empresa.com",
"ruta_descarga": "./adjuntos/empresa",
"ruta_final": "./resultado/empresa",
"base_datos": "SBO_EMPRESA_XX",
"usuario": "manager",
"password": "CONTRASENA_SAP",
"smb_folder": "/COMPRAS/EMPRESA/ADJUNTOS_CORREOS/2025-03"
}
]
}
```
### Secciones
#### hana
Conexion a SAP HANA Database para consultas de datos de negocio.
| Campo | Descripcion |
|-------|-------------|
| `server` | IP o hostname del servidor HANA |
| `port` | Puerto (por defecto 30015) |
| `user` | Usuario de conexion |
| `password` | Contrasena |
#### outlook
Credenciales de la aplicacion registrada en Azure AD para Microsoft Graph API.
| Campo | Descripcion |
|-------|-------------|
| `client_id` | ID de la aplicacion en Azure AD |
| `client_secret` | Secreto de la aplicacion |
| `tenant_id` | ID del tenant de Azure AD |
| `download_folder` | Carpeta temporal para descarga de adjuntos |
| `verify_ssl` | `true` para verificar certificados SSL, `false` para omitir |
| `ca_bundle` | Ruta a bundle CA personalizado, `null` para usar el del sistema |
**Como obtener credenciales de Outlook:**
1. Ir a [Azure Portal](https://portal.azure.com) > Azure Active Directory > App registrations
2. Crear una nueva aplicacion o usar una existente
3. Configurar permisos de Microsoft Graph: `Mail.Read` (delegated)
4. Copiar el Application (client) ID, Directory (tenant) ID, y crear un Client Secret
#### smb
Configuracion del servidor de archivos compartidos SMB/CIFS.
| Campo | Descripcion |
|-------|-------------|
| `server` | IP o hostname del servidor SMB |
| `username` | Usuario de autenticacion |
| `password` | Contrasena |
| `share` | Nombre del recurso compartido |
| `mount_points` | Puntos de montaje segun el sistema operativo |
Los `mount_points` se usan automaticamente segun donde se ejecute:
- `docker` se usa cuando corre en contenedor
- `linux`, `mac`, `windows` para ejecucion local
#### mails
Lista de buzones de correo a monitorear. Cada entrada es un buzón independiente.
| Campo | Descripcion |
|-------|-------------|
| `email` | Direccion del buzón de correo |
| `ruta_descarga` | Carpeta temporal para descargar adjuntos de este buzón |
| `ruta_final` | Carpeta donde se mueven los archivos procesados |
| `base_datos` | Codigo de base de datos SAP Business One |
| `usuario` | Usuario de SAP Business One |
| `password` | Contrasena de SAP Business One |
| `smb_folder` | Ruta destino en el share SMB para sincronizar |
Se pueden configurar multiples buzones en el array `mails`.
---
## scheduler.json
Define los horarios de operacion del daemon.
### Estructura
```json
{
"HORARIOS": [
[6, 30, 13, 15, 300, "rapido"],
[13, 15, 14, 0, 3600, "full"],
[14, 0, 17, 30, 300, "rapido"],
[17, 30, 21, 30, 3600, "full"]
],
"DIAS_ACTIVOS": [0, 1, 2, 3, 4]
}
```
### HORARIOS
Cada entrada es un array con 6 valores:
```
[hora_inicio, minuto_inicio, hora_fin, minuto_fin, intervalo_segundos, modo]
```
| Indice | Descripcion |
|--------|-------------|
| 0 | Hora de inicio (0-23) |
| 1 | Minuto de inicio (0-59) |
| 2 | Hora de fin (0-23) |
| 3 | Minuto de fin (0-59) |
| 4 | Intervalo entre ciclos en segundos |
| 5 | Modo de operacion (`rapido` o `full`) |
**Ejemplo:** `[6, 30, 13, 15, 300, "rapido"]` significa:
- Activo desde las 06:30 hasta las 13:15
- Cada 300 segundos (5 minutos)
- Modo rapido
### DIAS_ACTIVOS
Array con los dias de la semana en formato numerico:
| Valor | Dia |
|-------|-----|
| 0 | Lunes |
| 1 | Martes |
| 2 | Miercoles |
| 3 | Jueves |
| 4 | Viernes |
| 5 | Sabado |
| 6 | Domingo |
**Ejemplo:** `[0, 1, 2, 3, 4]` = Lunes a Viernes
### Modos de operacion
- **rapido**: Busca correos con frecuencia alta (cada 5 min). Indicado para horas pico.
- **full**: Busca correos con frecuencia baja (cada 1 hora). Indicado para horas de baja actividad.
---
## Variables de Entorno
| Variable | Valor | Descripcion |
|----------|-------|-------------|
| `PYTHONUNBUFFERED` | `1` | Desactiva buffering de Python para logs en tiempo real |
| `TZ` | `America/Lima` | Zona horaria del contenedor |
Estas se configuran en el `Dockerfile` y `docker-compose.yml`.
---
## Seguridad
- **Nunca commitear** `config.json` al repositorio (ya esta en `.gitignore`)
- Usar volumenes Docker montados como solo lectura (`:ro`) para archivos sensibles
- En produccion, considerar usar Docker secrets o variables de entorno en vez de archivos JSON
+76
View File
@@ -0,0 +1,76 @@
# Diagrama de Flujo - Descarga Adjuntos Correos CPE
```mermaid
graph TD
Start((<b>INICIO</b><br/>main.py)) --> Init[setup_logger + limpiar_logs_antiguos<br/>init_db SQLite]
Init --> Loop{<b>while _running</b>}
Loop --> RotarLog[rotar_log - rotacion diaria]
RotarLog --> CheckDay{Dia activo?<br/>weekdays 0-4}
CheckDay -->|No| SleepDay[Esperar hasta<br/>proximo dia activo]
SleepDay --> Loop
CheckDay -->|Si| CheckTime{Dentro de<br/>horario?}
CheckTime -->|No| SleepTime[Esperar hasta<br/>06:30]
SleepTime --> Loop
CheckTime -->|Si| Token[obtener_token<br/>MSAL - renueva cada 50min]
Token --> LoopAccounts
subgraph LoopAccounts [Bucle por cada Cuenta de Correo - config.mails]
direction TB
Process[procesar_cuenta]
Process --> GetEmails[get_emails<br/>Graph API]
GetEmails --> FilterNew[filtrar_correos_nuevos<br/>SQLite - IDs ya procesados]
FilterNew --> HasNew{Hay correos<br/>nuevos?}
HasNew -->|No| Skip[Sin correos nuevos]
HasNew -->|Si| EmailLoop
subgraph EmailLoop [Bucle por cada Correo Nuevo con Adjuntos]
direction TB
Download[download_attachments<br/>Graph API]
Download --> Unzip[procesar_adjuntos_email<br/>Descomprimir ZIP + filtrar PDF/XML]
Unzip --> ExtractMeta[procesar_adjuntos_enriquecidos<br/>obtener_datos_xml + extraer_texto_pdf]
ExtractMeta --> Classify{Clasificar<br/>metadatos}
Classify -->|Completos| Rename[renombrar_archivos_por_metadata<br/>F001.xml -> RUC-01-F001.xml]
Classify -->|Parciales| Evaluar[mover_a_evaluar<br/>revision manual]
Classify -->|Vacios| Delete[limpiar_carpeta_descarga]
Rename --> MoveResult[mover_validos_a_resultado<br/>a /resultado/empresa]
MoveResult --> Cleanup[limpiar_carpeta_descarga]
end
Skip --> SMBSync
EmailLoop --> SMBSync[sincronizar_a_smb<br/>rsync a carpeta compartida]
end
LoopAccounts --> ExportJSON[guardar_json<br/>/output_json/timestamp.json]
ExportJSON --> Sleep[Esperar intervalo<br/>segundos - 5min o 1hr]
Sleep --> Loop
Loop -->|senal SIGTERM/SIGINT| Stop((<b>FIN</b>))
style Start fill:#f9f,stroke:#333,stroke-width:2px
style Stop fill:#f9f,stroke:#333,stroke-width:2px
style Loop fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
style LoopAccounts fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 5 5
style EmailLoop fill:#fff3e0,stroke:#e65100
style SMBSync fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
style Classify fill:#fce4ec,stroke:#c62828
```
## Flujo resumido
1. **Inicio**: Configura logger con rotacion diaria, limpia logs viejos (>30 dias), inicializa SQLite
2. **Loop principal**: Verifica dia activo y horario antes de procesar
3. **Token**: Obtiene/renueva token OAuth de Microsoft Graph (valido 60 min)
4. **Por cada cuenta de correo**:
- Consulta correos via Graph API
- Filtra los ya procesados (SQLite)
- Descarga adjuntos, descomprime ZIPs
- Extrae metadata de XML y PDF (con fallback a LLM/Ollama)
- Clasifica: completos (renombrar+mover), parciales (evaluar/), vacios (eliminar)
5. **SMB**: Sincroniza archivos procesados a carpeta compartida via rsync
6. **Exporta**: Guarda resultados en JSON con timestamp
7. **Duerme**: Espera el intervalo configurado (5min rapido / 1hr full)
-53
View File
@@ -1,53 +0,0 @@
```mermaid
graph TD
Start((<b>INICIO</b><br/>main.py)) --> Init[Configurar Logger y<br/>Base de Datos SQLITE]
Init --> Auth[Obtener Token de Acceso<br/>Outlook API]
subgraph MailConfigLoop [Bucle por cada Cuenta de Correo en config.json]
direction TB
GetEmails[Consultar correos del día<br/>outlook_utilities.get_emails]
GetEmails --> FilterNew[Filtrar correos no procesados<br/>email_services.filtrar_correos_nuevos]
subgraph EmailLoop [Bucle por cada Correo Nuevo con Adjuntos]
direction TB
Download[Descargar adjuntos a /adjuntos/temp<br/>outlook_utilities.download_attachments]
Download --> Unzip[Descomprimir ZIPs y filtrar archivos<br/>email_services.procesar_adjuntos_email]
subgraph DataExtraction [Extracción de Metadatos]
direction LR
ParseXML[Parsear XML<br/>document_processor.obtener_datos_xml]
ParsePDF[Parsear PDF multilínea<br/>pdf_utilities.obtener_datos_pdf]
end
Unzip --> DataExtraction
DataExtraction --> Grouping[Lógica de Agrupación y Renombrado<br/>file_manager.procesar_renombrado_agrupado]
Grouping --> Rename[Renombrado físico de archivos<br/>F001.xml -> RUC-01-F001.xml]
Rename --> MoveLocal[Mover archivos agrupados a /resultado/<br/>file_manager.mover_archivos_a_resultado]
end
FilterNew --> EmailLoop
EmailLoop --> SMBSync[<b>Sincronización SMB</b><br/>smb_utils.sincronizar_a_smb]
subgraph SMBSteps [Proceso SMB]
direction LR
Mount[Montar Unidad según SO<br/>mount_smbfs / net use] --> Rsync[Ejecutar rsync -av<br/>--remove-source-files]
end
SMBSync --- SMBSteps
end
Auth --> MailConfigLoop
MailConfigLoop --> ExportJSON[Exportar resultados finales a JSON<br/>/output_json/YYYY-MM-DD.json]
ExportJSON --> End((<b>FIN</b>))
style Start fill:#f9f,stroke:#333,stroke-width:2px
style End fill:#f9f,stroke:#333,stroke-width:2px
style MailConfigLoop fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 5 5
style EmailLoop fill:#fff3e0,stroke:#e65100
style SMBSync fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px
```
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -40,7 +40,7 @@ def renombrar_archivos_por_metadata(metadatos, ruta_base):
if os.path.exists(ruta_old):
if os.path.exists(ruta_new) and ruta_old != ruta_new:
os.remove(ruta_new)
os.rename(ruta_old, ruta_new)
shutil.move(ruta_old, ruta_new)
m['archivo'] = nuevo_nombre
except Exception as e:
print(f" ⚠️ Error renombrando '{nombre_original}': {e}")
@@ -69,7 +69,7 @@ def mover_validos_a_resultado(metadatos, ruta_origen, ruta_destino):
if os.path.exists(ruta_old):
if os.path.exists(ruta_new) and ruta_old != ruta_new:
os.remove(ruta_new)
os.rename(ruta_old, ruta_new)
shutil.move(ruta_old, ruta_new)
movidos += 1
except Exception as e:
print(f" ⚠️ Error trasladando '{archivo}' a resultado: {e}")
@@ -99,7 +99,7 @@ def mover_a_evaluar(metadatos, ruta_origen, ruta_evaluar):
if os.path.exists(ruta_old):
if os.path.exists(ruta_new) and ruta_old != ruta_new:
os.remove(ruta_new)
os.rename(ruta_old, ruta_new)
shutil.move(ruta_old, ruta_new)
movidos += 1
except Exception as e:
print(f" ⚠️ Error moviendo '{archivo}' a evaluar: {e}")
+50 -28
View File
@@ -2,13 +2,15 @@ import sys
import os
from datetime import datetime, timedelta
# ========================================================================
_stdout_original = sys.stdout
_stderr_original = sys.stderr
_file_stream = None
_log_dir = "logs"
# ============================================================================
class MultiStream:
"""
Clase para redirigir la salida a múltiples flujos (consola y archivo).
"""
def __init__(self, *streams):
self.streams = streams
self.streams = list(streams)
def write(self, data):
for stream in self.streams:
@@ -19,31 +21,51 @@ class MultiStream:
for stream in self.streams:
stream.flush()
# ========================================================================
def reemplazar_file(self, old_fh, new_fh):
for i, s in enumerate(self.streams):
if s is old_fh:
self.streams[i] = new_fh
return
# ============================================================================
def _ruta_log(hoy=None):
if hoy is None:
hoy = datetime.now().strftime("%Y-%m-%d")
if not os.path.exists(_log_dir):
os.makedirs(_log_dir)
return os.path.join(_log_dir, f"{hoy}.log")
# ============================================================================
def setup_logger():
"""
Configura la redirección de stdout y stderr a un archivo de log y a la consola.
Retorna la ruta del archivo de log creado.
"""
log_dir = "logs"
if not os.path.exists(log_dir):
os.makedirs(log_dir)
# Nombre del archivo: YYYY-MM-DD_HH-MM-SS.log
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
log_filename = f"{timestamp}.log"
log_path = os.path.join(log_dir, log_filename)
# Abrir archivo en modo append con encoding utf-8 para soportar iconos
log_file = open(log_path, "a", encoding="utf-8")
# Redirigir stdout y stderr
sys.stdout = MultiStream(sys.stdout, log_file)
sys.stderr = MultiStream(sys.stderr, log_file)
global _file_stream
log_path = _ruta_log()
_file_stream = open(log_path, "a", encoding="utf-8")
sys.stdout = MultiStream(_stdout_original, _file_stream)
sys.stderr = MultiStream(_stderr_original, _file_stream)
return log_path
# ========================================================================
# ============================================================================
def rotar_log():
global _file_stream
hoy = datetime.now().strftime("%Y-%m-%d")
log_path = _ruta_log(hoy)
if _file_stream and os.path.abspath(_file_stream.name) == os.path.abspath(log_path):
return
nuevo_fh = open(log_path, "a", encoding="utf-8")
if isinstance(sys.stdout, MultiStream):
sys.stdout.reemplazar_file(_file_stream, nuevo_fh)
if isinstance(sys.stderr, MultiStream):
sys.stderr.reemplazar_file(_file_stream, nuevo_fh)
try:
_file_stream.close()
except Exception:
pass
_file_stream = nuevo_fh
print(f"📄 Log rotado a: {log_path}")
# ============================================================================
def limpiar_logs_antiguos(dias=7):
"""
Elimina archivos de log con más de 'n' días de antigüedad.
@@ -68,4 +90,4 @@ def limpiar_logs_antiguos(dias=7):
if count > 0:
print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).")
# ========================================================================
# ============================================================================
+2 -1
View File
@@ -12,7 +12,7 @@ import document_processor
import file_manager
import llm_processor
from smb_manager import sincronizar_a_smb
from logger_utilities import setup_logger, limpiar_logs_antiguos
from logger_utilities import setup_logger, limpiar_logs_antiguos, rotar_log
from config_loader import CONFIG
@@ -227,6 +227,7 @@ def main():
fecha_obtencion_token = None
while _running:
rotar_log()
ahora = datetime.now()
if ahora.weekday() not in DIAS_ACTIVOS: