From cf2c1ee147c987dcfa6023e1de4e540cfcc1cac7 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 4 Aug 2026 17:57:43 -0500 Subject: [PATCH] =?UTF-8?q?se=20agrega=20archivos=20para=20la=20instalaci?= =?UTF-8?q?=C3=B3n=20en=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 12 + .gitignore | 1 + Dockerfile | 16 ++ config.json | 5 +- config_loader.py | 4 +- database_utilities.py | 43 +++- docker-compose.yml | 17 ++ mermaid.md => docs/mermaid.md | 0 document_processor.py | 32 +-- email_services.py | 23 +- emails.db | Bin 77824 -> 278528 bytes file_manager.py | 260 ++++++++------------ llm_processor.py | 50 ++++ logger_utilities.py | 7 +- main.py | 401 ++++++++++++++++++++----------- outlook_utilities.py | 20 +- pdf_utilities.py | 142 ++++++++--- requirements.txt | 3 +- scheduler.json | 9 + smb_manager.py | 144 +++++++++++ smb_utils.py | 126 ---------- tests/__init__.py | 0 tests/conftest.py | 110 +++++++++ tests/test_database_utilities.py | 73 ++++++ tests/test_document_processor.py | 98 ++++++++ tests/test_email_services.py | 108 +++++++++ tests/test_file_manager.py | 128 ++++++++++ tests/test_pdf_utilities.py | 99 ++++++++ 28 files changed, 1430 insertions(+), 501 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml rename mermaid.md => docs/mermaid.md (100%) create mode 100644 llm_processor.py create mode 100644 scheduler.json create mode 100644 smb_manager.py delete mode 100644 smb_utils.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_database_utilities.py create mode 100644 tests/test_document_processor.py create mode 100644 tests/test_email_services.py create mode 100644 tests/test_file_manager.py create mode 100644 tests/test_pdf_utilities.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1128a78 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +tests/ +__pycache__ +*.pyc +*.log +.env +output_json/ +logs/ +adjuntos/ +resultado/ +data/ +.DS_Store diff --git a/.gitignore b/.gitignore index eb73e0d..1009f82 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ logs/ output_json/ resultado/ .DS_Store +emails.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..40cd267 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + tzdata \ + rsync \ + && rm -rf /var/lib/apt/lists/* + +COPY *.py ./ + +CMD ["python", "main.py"] diff --git a/config.json b/config.json index efbd73e..40ac642 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,7 @@ }, "outlook": { "client_id": "957c324b-3da3-43ed-b912-ca52fb5d3128", - "client_secret": "Guc8Q~u6FH3xBKpxJ-hQ7QSpRWbnvmX.LnWr-dw7", + "client_secret": "iWK8Q~CsGhMnjvsPN_jz3O0fwSYY3SSolfuYycr8", "tenant_id": "76f77b2f-6fc3-42a0-ae83-df05508405d6", "download_folder": "./adjuntos", "verify_ssl": false, @@ -21,7 +21,8 @@ "mount_points": { "windows": "S:", "linux": "/mnt/FileSAP", - "mac": "/Users/george/FileSAP" + "mac": "/Users/george/FileSAP", + "docker": "/mnt/FileSAP" } }, "mails": [ diff --git a/config_loader.py b/config_loader.py index 11ab3ab..367db85 100644 --- a/config_loader.py +++ b/config_loader.py @@ -2,7 +2,7 @@ import json from pathlib import Path from config_model import Config, HanaConfig, OutlookConfig, MailConfig, SmbConfig -######################################################## +# ======================================================================== CONFIG_PATH = Path(__file__).resolve().parent / "config.json" with open(CONFIG_PATH, 'r', encoding='utf-8') as f: @@ -21,4 +21,4 @@ CONFIG = Config( mails=mails ) -######################################################## \ No newline at end of file +# ======================================================================== \ No newline at end of file diff --git a/database_utilities.py b/database_utilities.py index 7d10c44..5321117 100644 --- a/database_utilities.py +++ b/database_utilities.py @@ -2,7 +2,7 @@ import sqlite3 DB_NAME = "emails.db" -######################################################## +# ======================================================================== def init_db(): """ Inicializa la base de datos SQLite y crea la tabla de correos si no existe. @@ -25,7 +25,7 @@ def init_db(): conn.commit() conn.close() -######################################################## +# ======================================================================== def save_emails(emails_list): """ Guarda una lista de correos en la base de datos. @@ -65,7 +65,7 @@ def save_emails(emails_list): conn.close() return rows_affected -######################################################## +# ======================================================================== def get_existing_ids(ids_list): """ Recibe una lista de IDs y devuelve una lista con aquellos que ya existen en la BD. @@ -88,7 +88,7 @@ def get_existing_ids(ids_list): conn.close() return existing_ids -######################################################## +# ======================================================================== def delete_email_by_id(email_id): """ Elimina un correo de la base de datos por su ID. @@ -107,4 +107,37 @@ def delete_email_by_id(email_id): conn.close() return rows_affected > 0 -######################################################## \ No newline at end of file + +# ======================================================================== +def get_ids_by_fecha(fecha): + """ + Retorna una lista de IDs cuya fecha sea >= al parámetro recibido. + Formato esperado de fecha: 'YYYY-MM-DD' o 'YYYY-MM-DD HH:MM:SS' + """ + if not fecha: + return [] + + conn = sqlite3.connect(DB_NAME) + cursor = conn.cursor() + + cursor.execute("SELECT id FROM emails WHERE fecha >= ?", (fecha,)) + ids = [row[0] for row in cursor.fetchall()] + + conn.close() + return ids +# ======================================================================== +if __name__ == "__main__": + fecha_inicio = '2026-07-31' + print(f"\nObteniendo IDs mayores o iguales a {fecha_inicio}:") + + ids_encontrados = get_ids_by_fecha(fecha_inicio) + + if ids_encontrados: + print(f"✓ Se encontraron {len(ids_encontrados)} correos desde esa fecha:") + for i, email_id in enumerate(ids_encontrados, 1): + print(f" {i}. {email_id}") + # delete_email_by_id(email_id) + if len(ids_encontrados) > 10: + print(f" ... y {len(ids_encontrados) - 10} más") + else: + print("✗ No se encontraron correos desde esa fecha.") \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0bf8e6d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + descarga-cpe: + build: . + container_name: descarga_adjuntos_cpe + restart: unless-stopped + network_mode: host + environment: + - TZ=America/Lima + volumes: + - ./config.json:/app/config.json:ro + - ./scheduler.json:/app/scheduler.json:ro + - ./data:/app/data + - ./logs:/app/logs + - ./output_json:/app/output_json + - ./adjuntos:/app/adjuntos + - ./resultado:/app/resultado + - /mnt/FileSAP:/mnt/FileSAP diff --git a/mermaid.md b/docs/mermaid.md similarity index 100% rename from mermaid.md rename to docs/mermaid.md diff --git a/document_processor.py b/document_processor.py index 801415a..36a1e86 100644 --- a/document_processor.py +++ b/document_processor.py @@ -2,7 +2,10 @@ import os import xml.etree.ElementTree as ET import pdf_utilities -######################################################## +# Constante única de tipos SUNAT válidos +TIPOS_SUNAT = ["01", "02", "03", "07", "08", "09", "14", "CDR"] + +# ======================================================================== def formatear_serie_numero(serie_num): """ Remueve los ceros a la izquierda de la parte numérica de la serie-número. @@ -19,19 +22,17 @@ def formatear_serie_numero(serie_num): pass return serie_num -######################################################## +# ======================================================================== def generar_nombre_propuesto(m): """ Genera un nombre de archivo propuesto basado en ruc, tipo y serie-numero. - Soporta nombres descriptivos y códigos numéricos de SUNAT. + Retorna None si no hay metadata suficiente para generar un nombre. """ - # Incluimos 99 como tipo permitido para nombres propuestos si se desea - tipos_permitidos = ["01", "03", "07", "08", "02", "09", "14"] - if m['tipo'] in tipos_permitidos and m['ruc'] not in ["Desconocido", "N/A"]: + if m['tipo'] in TIPOS_SUNAT and m['ruc'] not in ["Desconocido", "N/A"]: return f"{m['ruc']}-{m['tipo']}-{m['serie_numero']}" return None -######################################################## +# ======================================================================== def obtener_datos_xml(ruta_xml): """ Analiza un XML de SUNAT para extraer tipo de documento, serie, número y RUC. @@ -101,7 +102,7 @@ def obtener_datos_xml(ruta_xml): except Exception as e: return {"valido": False, "error": str(e)} -######################################################## +# ======================================================================== def procesar_adjuntos_enriquecidos(email_obj, ruta_base): """ Recorre los archivos filtrados de un email y extrae su metadata (XML o PDF). @@ -111,8 +112,8 @@ def procesar_adjuntos_enriquecidos(email_obj, ruta_base): archivos = email_obj.get('archivos', []) metadatos_adjuntos = [] - # Tipos permitidos oficialmente (incluimos CDR por requerimiento) - tipos_ok = ["01", "02", "03", "07", "08", "CDR"] + # Usar la constante global de tipos SUNAT + tipos_ok = TIPOS_SUNAT xmls = [a for a in archivos if a.lower().endswith('.xml')] pdfs = [a for a in archivos if a.lower().endswith('.pdf')] @@ -145,7 +146,9 @@ def procesar_adjuntos_enriquecidos(email_obj, ruta_base): "tipo": tipo_final, "serie_numero": formatear_serie_numero(datos['id']), "ruc": datos['ruc'], - "fuente": "PDF" + "fuente": "PDF", + "llm_usado": datos.get("llm_usado", False), + "llm_respuesta": datos.get("llm_respuesta") } m["archivo_propuesto"] = generar_nombre_propuesto(m) metadatos_adjuntos.append(m) @@ -157,9 +160,10 @@ def procesar_adjuntos_enriquecidos(email_obj, ruta_base): "serie_numero": "N/A", "ruc": "N/A", "fuente": "PDF_FALLIDO", - "archivo_propuesto": None + "archivo_propuesto": None, + "llm_usado": False, + "llm_respuesta": None }) return metadatos_adjuntos - - ######################################################## \ No newline at end of file +# ======================================================================== \ No newline at end of file diff --git a/email_services.py b/email_services.py index 5ee95f7..dc34df2 100644 --- a/email_services.py +++ b/email_services.py @@ -2,20 +2,19 @@ import os import zipfile import database_utilities -######################################################## +# ======================================================================== def filtrar_correos_nuevos(emails): """ - Verifica cuáles correos son nuevos (no están en la BD), los guarda - y retorna la lista filtrada de los que tienen adjuntos. + Verifica cuáles correos son nuevos (no están en la BD), los guarda en la BD + y retorna la lista completa de nuevos (con o sin adjuntos). + Todos se registran en DB para no reprocesarlos en futuras ejecuciones. """ if not emails: return [] - # 1. Verificar cuáles ya existen en la BD ids = [e['id'] for e in emails] existentes = database_utilities.get_existing_ids(ids) - # Solo nos quedamos con los que NO están en la BD nuevos = [e for e in emails if e['id'] not in existentes] print(f"Correos nuevos detectados: {len(nuevos)}") @@ -23,13 +22,9 @@ def filtrar_correos_nuevos(emails): print("Guardando nuevos correos en la base de datos...") database_utilities.save_emails(nuevos) - # 2. Filtrar para quedarse solo con los que tienen adjuntos - con_adjuntos = [e for e in nuevos if e['has_attach']] - print(f"Correos nuevos con adjuntos: {len(con_adjuntos)}") - - return con_adjuntos + return nuevos -######################################################## +# ======================================================================== def descomprimir_adjuntos(archivos, ruta): """ Descomprime archivos .zip en la ruta especificada y actualiza la lista de archivos. @@ -51,7 +46,7 @@ def descomprimir_adjuntos(archivos, ruta): archivos_finales.append(archivo) return archivos_finales -######################################################## +# ======================================================================== def filtrar_archivos_validos(archivos): """ Retorna una lista solo con archivos .pdf y .xml (ignora mayúsculas/minúsculas). @@ -59,7 +54,7 @@ def filtrar_archivos_validos(archivos): extensiones_validas = ('.pdf', '.xml') return [a for a in archivos if a.lower().endswith(extensiones_validas)] -######################################################## +# ======================================================================== def procesar_adjuntos_email(e, archivos, ruta_descarga): """ Descomprime y filtra los archivos de un correo. @@ -76,5 +71,5 @@ def procesar_adjuntos_email(e, archivos, ruta_descarga): return archivos_validos return [] -######################################################## +# ======================================================================== diff --git a/emails.db b/emails.db index af80789dae0184db0bd715747b1ab23aaadc0b80..242b42f0e5c3bdc1c0e452488a9e03f478d52101 100644 GIT binary patch literal 278528 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 delta 991 zcmZ`&O>7fK7@coscGtUcb{s-zpx|skfJ#X44+I4@5FgSCHA<*J;ZF!0PCz9fR28ZC zi4X_GpIQxOFtoA{(9#p&#cX9F5q=^BTnG^q3EUJ&S`IV_+8`*jbat)41#a&f&3^CA zdv7~COFDm}-+ZVT2%*w%t$8H|s`+Y9F?)`%Ie^Rob}d_p*ZHy`tgH5bX~8(1^~t+v z6nn{@u_x>i`-9zQd3Kxi*}+hj@xF?1t>W)>?*!NiGiQF_?=AjPzot+3?bC*|7WJfB zqO{XKI+G;uGrSmnhOy{t*5?0|#{&%_Foh&x9%v)Qh8F!f&=9&ZeB1a74P(aQuJey&sd{h z)vJ7awLi6Hsd}ELTBOgA!dAY0lLAvc0_Wmsk_SFxv*PTwNgasaGbF`3wL#k%SXHXZ`Haw zQOAg$1YfyRcLtj9FW|IIqj_-7HseO{Y)~6r${G*z4SS`lYq0C;I}8`n@N{MaSQ;Ki zWs;u&Qv7_)5%*kN((!jreG|S5 ze8CCotTyoXiik5Ax|iC=9(E?r$2)K)O)XMNqqf#v1oq??Il|L9#qPZnt*VvnLGP{A zNNj`JkQbe9tSwY=q-+VdJ@&SL^I%v1{hadUuT!F70w}QAEm?wZy{p3#7beB>3Vf`9 z&W*;$cze$wr>h3$g7|eJ{9uoLatrwWztqzJfwJNuq^UVnbX?L+^eJl2eCY3yf6`Gk zI|!Ev5BBeJ$aomUx^(299re-1*}SAbQSP}G2}NqFL$j*GGp$hFobaqUktkmk9ACCD u*1V`?p|vcwAhsxGHHJdr>TuY(8H5>-rMOZr`11H^iub=t*{9Awfd2u<<2jB1 diff --git a/file_manager.py b/file_manager.py index 9833773..c625460 100644 --- a/file_manager.py +++ b/file_manager.py @@ -1,188 +1,132 @@ import os +import shutil -######################################################## -def calcular_puntuacion(nombre_archivo, ruc, tipo, serie_numero): - """ - Calcula una puntuación de 0 a 4 según cuántos elementos coinciden en el nombre del archivo. - serie_numero se descompone en serie y número. - """ - puntos = 0 - nombre_archivo = nombre_archivo.upper() - - # 1. RUC - if ruc and ruc != "N/A" and ruc in nombre_archivo: - puntos += 1 - - # 2. Tipo - if tipo and tipo != "99" and tipo in nombre_archivo: - puntos += 1 - - # 3. Serie y 4. Número - if "-" in serie_numero: - partes = serie_numero.split("-") - serie = partes[0].upper() - numero = partes[1] - - if serie in nombre_archivo: - puntos += 1 - if numero in nombre_archivo: - puntos += 1 - - return puntos +# ======================================================================== +def tiene_datos_parciales(m): + """True si el archivo tiene al menos un campo útil, aunque incompleto.""" + if m['ruc'] not in ("N/A", "Desconocido"): + return True + if m['serie_numero'] not in ("No encontrado", "N/A"): + return True + if m['tipo'] != "99": + return True + return False -######################################################## -def procesar_renombrado_agrupado(email_obj, ruta_base): +# ======================================================================== +def renombrar_archivos_por_metadata(metadatos, ruta_base): """ - Lógica de agrupación y renombrado de archivos según puntuación y tipo. + Renombra cada archivo según su propia metadata extraída. + - XML/PDF con archivo_propuesto válido: se renombra a {archivo_propuesto}{extension} + - CDR (tipo 'CDR'): se renombra igual (archivo_propuesto ya incluye CDR) + - Sin metadata válida (archivo_propuesto is None): se deja intacto (será eliminado al limpiar) + Retorna la lista de metadatos actualizada con los nombres finales. """ - metadatos = email_obj.get('metadatos_adjuntos', []) - tipos_permitidos = ["01", "03", "07", "08", "02", "09", "14"] - - # 1. Identificar "Pivotes" (pivotes para agrupar) - # Preferencia: XML Maestros. Fallback: PDFs con metadata válida si no hay XMLs. - xml_maestros = [m for m in metadatos if m['extension'] == ".xml" and m['tipo'] in tipos_permitidos] - - if xml_maestros: - pivotes = xml_maestros - else: - # En correos solo PDF, tratamos a cada PDF con nombre propuesto como un pivote - pivotes = [m for m in metadatos if m['extension'] == ".pdf" and m['archivo_propuesto'] is not None] - - if not pivotes: - return {} # Nada que agrupar - - nuevos_archivos = [] - mapeo_renombrado = {} # original -> nuevo - agrupacion = {} # archivo_propuesto -> [lista de nombres nuevos] - - # Inicializar el diccionario de agrupación con los nombres de los pivotes - for p in pivotes: - agrupacion[p['archivo_propuesto']] = [] - - # 2. Agrupar cada archivo con su mejor pivote for m in metadatos: nombre_original = m['archivo'] - extension = m['extension'].lower() - - # Si es un pivote, su nombre es directo y ya está inicializado - if m in pivotes: - nombre_nuevo = f"{m['archivo_propuesto']}{extension}" - mapeo_renombrado[nombre_original] = nombre_nuevo - agrupacion[m['archivo_propuesto']].append(nombre_nuevo) - else: - # Buscar el mejor match entre los pivotes que aún acepten este tipo de archivo - mejor_pivote = None - max_puntos = -1 - - is_cdr = m['tipo'] == "CDR" or "R-" in nombre_original or "R_" in nombre_original or "CDR" in nombre_original.upper() - is_pdf = extension == ".pdf" - is_xml = extension == ".xml" and not is_cdr - - for p in pivotes: - p_prop = p['archivo_propuesto'] - grupo_actual = agrupacion[p_prop] - - ya_tiene_pdf = any(f.lower().endswith('.pdf') for f in grupo_actual) - ya_tiene_cdr = any(f.startswith('CDR-') for f in grupo_actual) - - if (is_pdf and ya_tiene_pdf) or (is_cdr and ya_tiene_cdr) or is_xml: - continue - - puntos = calcular_puntuacion( - nombre_original, - p['ruc'], - p['tipo'], - p['serie_numero'] - ) - - if puntos > max_puntos: - max_puntos = puntos - mejor_pivote = p - - if mejor_pivote and max_puntos > 0: - prefix = "CDR-" if is_cdr else "" - nombre_nuevo = f"{prefix}{mejor_pivote['archivo_propuesto']}{extension}" - - mapeo_renombrado[nombre_original] = nombre_nuevo - agrupacion[mejor_pivote['archivo_propuesto']].append(nombre_nuevo) - else: - # Si no hay match, va a OTROS - mapeo_renombrado[nombre_original] = nombre_original - if "OTROS" not in agrupacion: agrupacion["OTROS"] = [] - agrupacion["OTROS"].append(nombre_original) + extension = m['extension'] + propuesto = m.get('archivo_propuesto') - # 3. Ejecutar el renombrado físico y actualizar metadatos - for m in metadatos: - nombre_original = m['archivo'] - nombre_nuevo = mapeo_renombrado.get(nombre_original) - - if nombre_nuevo and nombre_nuevo != nombre_original: - ruta_old = os.path.join(ruta_base, nombre_original) - ruta_new = os.path.join(ruta_base, nombre_nuevo) - - try: - if os.path.exists(ruta_old): - # Si el destino ya existe, borramos para sobreescribir (o podrías manejarlo de otra forma) - if os.path.exists(ruta_new) and ruta_old != ruta_new: - os.remove(ruta_new) - os.rename(ruta_old, ruta_new) - m['archivo'] = nombre_nuevo - except Exception as e: - print(f" ⚠️ Error renombrando {nombre_original}: {e}") + if propuesto is None: + continue - # 4. Actualizar la lista de archivos en el email_obj - email_obj['archivos'] = [m['archivo'] for m in metadatos] - return agrupacion + nuevo_nombre = f"{propuesto}{extension}" + if nuevo_nombre == nombre_original: + continue -######################################################## -def mover_archivos_a_resultado(email_obj, ruta_origen, ruta_destino): + ruta_old = os.path.join(ruta_base, nombre_original) + ruta_new = os.path.join(ruta_base, nuevo_nombre) + + try: + 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) + m['archivo'] = nuevo_nombre + except Exception as e: + print(f" ⚠️ Error renombrando '{nombre_original}': {e}") + + return metadatos + +# ======================================================================== +def mover_validos_a_resultado(metadatos, ruta_origen, ruta_destino): """ - Mueve los archivos procesados de la carpeta temporal a la carpeta final de resultados. - Solo mueve archivos que pertenecen a un grupo válido (basado en archivo_propuesto). - Excluye los archivos en la categoría 'OTROS'. + Mueve solo los archivos que tienen metadata válida (archivo_propuesto no None) + desde la carpeta de descarga a la carpeta de resultados. """ - agrupacion = email_obj.get('agrupacion_archivos', {}) - if not agrupacion: - return - if not os.path.exists(ruta_destino): os.makedirs(ruta_destino) - print(f" 📁 Carpeta de destino creada: {ruta_destino}") - for key, archivos in agrupacion.items(): - if key == "OTROS": - continue # No mover archivos no identificados - - for archivo in archivos: - ruta_old = os.path.join(ruta_origen, archivo) - ruta_new = os.path.join(ruta_destino, archivo) - - try: - 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) - except Exception as e: - print(f" ⚠️ Error trasladando {archivo} a resultado: {e}") + movidos = 0 + for m in metadatos: + if m.get('archivo_propuesto') is None: + continue -######################################################## + archivo = m['archivo'] + ruta_old = os.path.join(ruta_origen, archivo) + ruta_new = os.path.join(ruta_destino, archivo) + + try: + 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) + movidos += 1 + except Exception as e: + print(f" ⚠️ Error trasladando '{archivo}' a resultado: {e}") + + if movidos: + print(f" 📦 {movidos} archivo(s) movido(s) a resultado.") + +# ======================================================================== +def mover_a_evaluar(metadatos, ruta_origen, ruta_evaluar): + """ + Mueve archivos con metadata parcial a la carpeta evaluar/ para revisión. + Conserva el nombre original del archivo (sin renombrar). + """ + if not metadatos: + return + + if not os.path.exists(ruta_evaluar): + os.makedirs(ruta_evaluar) + + movidos = 0 + for m in metadatos: + archivo = m['archivo'] + ruta_old = os.path.join(ruta_origen, archivo) + ruta_new = os.path.join(ruta_evaluar, archivo) + + try: + 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) + movidos += 1 + except Exception as e: + print(f" ⚠️ Error moviendo '{archivo}' a evaluar: {e}") + + if movidos: + print(f" 🔍 {movidos} archivo(s) movido(s) a evaluar (metadata parcial).") + +# ======================================================================== def limpiar_carpeta_descarga(ruta): """ - Elimina todos los archivos dentro de la carpeta de descarga para dejarla limpia. + Elimina todos los archivos y subcarpetas dentro de la carpeta de descarga. + Omite la subcarpeta 'evaluar/' para no borrar archivos pendientes de revisión. """ if not os.path.exists(ruta): return - - print(f" 🧹 Limpiando carpeta de descarga: {ruta}") + + print(f" 🧹 Limpiando carpeta de descarga") for archivo in os.listdir(ruta): + if archivo == "evaluar": + continue ruta_archivo = os.path.join(ruta, archivo) try: if os.path.isfile(ruta_archivo): os.remove(ruta_archivo) elif os.path.isdir(ruta_archivo): - import shutil shutil.rmtree(ruta_archivo) except Exception as e: - print(f" ⚠️ No se pudo eliminar {archivo}: {e}") + print(f" ⚠️ No se pudo eliminar '{archivo}': {e}") -######################################################## \ No newline at end of file +# ======================================================================== diff --git a/llm_processor.py b/llm_processor.py new file mode 100644 index 0000000..8cb13c8 --- /dev/null +++ b/llm_processor.py @@ -0,0 +1,50 @@ +import json +import requests + +# ======================================================================== +OLLAMA_URL = "http://localhost:11434/api/chat" +MODEL = "qwen3:8b" +CONTADOR_LLM = 0 + +_PROMPT_TEMPLATE = """Extrae los siguientes datos del comprobante SUNAT (factura, boleta, nota de credito, etc.) a partir del texto extraido del PDF. + +Texto del PDF: +{texto} + +Devuelve SOLO un JSON valido con estos 4 campos: +- ruc: string de 11 digitos (ej: 20101420320) +- tipo: string del tipo de documento segun codigo SUNAT (01=Factura, 03=Boleta, 07=Nota Credito, 08=Nota Debito, 09=Guia Remision, 14=Recibo Honorarios, 02=Recibo por Honorarios) +- serie: string (ej: F001, B001, E001, EG07) +- numero: string (ej: 00050193, 5103) + +JSON:""" + +# ======================================================================== +def extraer_con_llm(texto): + global CONTADOR_LLM + CONTADOR_LLM += 1 + try: + resp = requests.post( + OLLAMA_URL, + json={ + "model": MODEL, + "messages": [ + {"role": "system", "content": "Eres un extractor de datos de comprobantes SUNAT. Solo respondes con JSON valido."}, + {"role": "user", "content": _PROMPT_TEMPLATE.format(texto=texto[:8000])} + ], + "format": "json", + "stream": False, + "options": {"temperature": 0} + }, + timeout=180 + ) + data = resp.json() + content = data.get("message", {}).get("content", "") + return json.loads(content) + except json.JSONDecodeError: + return {} + except requests.ConnectionError: + return {} + except Exception: + return {} +# ======================================================================== \ No newline at end of file diff --git a/logger_utilities.py b/logger_utilities.py index 6d05b9e..c61e100 100644 --- a/logger_utilities.py +++ b/logger_utilities.py @@ -2,7 +2,7 @@ import sys import os from datetime import datetime, timedelta -######################################################## +# ======================================================================== class MultiStream: """ Clase para redirigir la salida a múltiples flujos (consola y archivo). @@ -19,7 +19,7 @@ class MultiStream: for stream in self.streams: stream.flush() -######################################################## +# ======================================================================== def setup_logger(): """ Configura la redirección de stdout y stderr a un archivo de log y a la consola. @@ -43,7 +43,7 @@ def setup_logger(): return log_path -######################################################## +# ======================================================================== def limpiar_logs_antiguos(dias=7): """ Elimina archivos de log con más de 'n' días de antigüedad. @@ -68,3 +68,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).") +# ======================================================================== \ No newline at end of file diff --git a/main.py b/main.py index 15da35a..edf7c2f 100644 --- a/main.py +++ b/main.py @@ -1,172 +1,293 @@ import sys import json import os +import time +import signal +from datetime import datetime, timedelta + import outlook_utilities import database_utilities import email_services -import logger_utilities import document_processor import file_manager -from datetime import datetime, timedelta +import llm_processor +from smb_manager import sincronizar_a_smb +from logger_utilities import setup_logger, limpiar_logs_antiguos from config_loader import CONFIG -######################################################## -if __name__ == "__main__": +# ============================================================================ +# CONFIGURACIÓN DE HORARIOS (cargado desde scheduler.json) +# ============================================================================ +_SCHEDULER_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scheduler.json") +with open(_SCHEDULER_PATH, "r", encoding="utf-8") as _f: + _sched = json.load(_f) - # Configurar Logger (Redirige stdout a archivo y consola) - log_path = logger_utilities.setup_logger() - logger_utilities.limpiar_logs_antiguos(dias=30) +HORARIOS = [tuple(h) for h in _sched["HORARIOS"]] +DIAS_ACTIVOS = _sched["DIAS_ACTIVOS"] - inicio_ejecucion = datetime.now() - print("="*60) - print(f"🚀 INICIO DE EJECUCIÓN: {inicio_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"📄 Log guardado en: {log_path}") - print("="*60) +_running = True + +# ─── Colores ──────────────────────────────────────────── +color_verde = "\033[92m" +color_amarillo = "\033[93m" +color_rojo = "\033[91m" +color_fin = "\033[0m" + +# ======================================================================== +def ic(ok, texto_ok, texto_fail): + icono = "✅" if ok else "⚠️" + color = color_verde if ok else color_amarillo + return f"{icono} {color}{texto_ok if ok else texto_fail}{color_fin}" + +# ======================================================================== +def obtener_intervalo(dt): + minutos = dt.hour * 60 + dt.minute + for hi, mi, hf, mf, intervalo, modo in HORARIOS: + inicio = hi * 60 + mi + fin = hf * 60 + mf + if inicio <= minutos < fin: + return (intervalo, modo) + return None + +# ======================================================================== +def segundos_hasta_proximo_inicio(dt): + target = dt.replace(hour=6, minute=30, second=0, microsecond=0) + if dt >= target: + target += timedelta(days=1) + return int((target - dt).total_seconds()) + +# ======================================================================== +def segundos_hasta_proximo_dia_activo(dt): + for dias in range(1, 8): + prox = dt + timedelta(days=dias) + if prox.weekday() in DIAS_ACTIVOS: + target = prox.replace(hour=6, minute=30, second=0, microsecond=0) + return int((target - dt).total_seconds()) + return 86400 + +# ======================================================================== +def obtener_token(outlook_token, fecha_obtencion): + """Reusa el token si fue obtenido hace menos de 50 minutos, si no renueva.""" + if outlook_token and fecha_obtencion: + delta = datetime.now() - fecha_obtencion + if delta.total_seconds() < 3000: # ~50 min (token MSAL dura 60 min) + return outlook_token + + print("\n🔑 Obteniendo nuevo token de acceso...") + nuevo = outlook_utilities.get_access_token() + print(f" {ic(True, 'Token obtenido.', '')}") + return nuevo + +# ======================================================================== +def procesar_cuenta(mail, outlook_token, fecha_inicio, fecha_fin): + """Procesa todos los correos nuevos de una cuenta.""" + print(f"\n{'='*60}") + print(f"📧 [{mail.email}]") + + emails = outlook_utilities.get_emails(mail.email, outlook_token, fecha_inicio, fecha_fin) + print(f" 📬 {len(emails)} correos en el rango.") + + correos_nuevos = email_services.filtrar_correos_nuevos(emails) + if not correos_nuevos: + print(f" {ic(True, 'Sin correos nuevos.', '')}") + return [] + + print(f" 🆕 {len(correos_nuevos)} nuevos (no procesados antes).") + procesados = [] + + for e in correos_nuevos: + if not _running: + if llm_processor.CONTADOR_LLM > 0: + print(f" 🤖 LLM invocado {llm_processor.CONTADOR_LLM} vez/veces (modelo: {llm_processor.MODEL})") + return procesados + if not e['has_attach']: + continue + + try: + archivos_orig = outlook_utilities.download_attachments( + mail.email, e['id'], outlook_token, mail.ruta_descarga + ) + if not archivos_orig: + continue + + print(f"\n ├── 📬 [{e['fecha']}] {e['subject'][:80]}...") + + archivos_validos = email_services.procesar_adjuntos_email( + e, archivos_orig.copy(), mail.ruta_descarga + ) + if not archivos_validos: + print(f" │ {ic(False, '', 'Descartado: sin PDF/XML.')}") + file_manager.limpiar_carpeta_descarga(mail.ruta_descarga) + continue + + metadatos = document_processor.procesar_adjuntos_enriquecidos(e, mail.ruta_descarga) + + for a in archivos_orig: + if a.lower().endswith('.zip'): + print(f" │ 📦 Descomprimido: {a}") + + # Clasificar archivos por completitud de metadata + completos = [m for m in metadatos if m.get('archivo_propuesto') is not None] + parciales = [m for m in metadatos if m.get('archivo_propuesto') is None and file_manager.tiene_datos_parciales(m)] + vacios = [m for m in metadatos if m.get('archivo_propuesto') is None and not file_manager.tiene_datos_parciales(m)] + + if not completos and not parciales: + print(f" │ {ic(False, '', 'Descartado: ningun archivo tiene datos utiles.')}") + file_manager.limpiar_carpeta_descarga(mail.ruta_descarga) + continue + + # Renombrar y mover completos a resultado + if completos: + file_manager.renombrar_archivos_por_metadata(completos, mail.ruta_descarga) + file_manager.mover_validos_a_resultado(completos, mail.ruta_descarga, mail.ruta_final) + + # Mover parciales a evaluar para revisión manual + if parciales: + ruta_evaluar = os.path.join(mail.ruta_descarga, "evaluar") + file_manager.mover_a_evaluar(parciales, mail.ruta_descarga, ruta_evaluar) + + # Limpiar lo que queda (vacíos + sobrantes) + file_manager.limpiar_carpeta_descarga(mail.ruta_descarga) + + for m in metadatos: + if m.get('archivo_propuesto') is not None: + if m.get('llm_usado'): + llm_resp = m.get('llm_respuesta') or {} + print(f" │ 🤖 {m['archivo']} -> [{m['tipo']}] RUC:{m['ruc']} S/N:{m['serie_numero']} | LLM: {llm_resp}") + else: + icono = "📕" if m['extension'] == ".pdf" else "🌐" + print(f" │ {icono} {m['archivo']} -> [{m['tipo']}] RUC:{m['ruc']} S/N:{m['serie_numero']}") + elif file_manager.tiene_datos_parciales(m): + print(f" │ 🔍 {m['archivo']} (metadata parcial -> evaluar/)") + else: + print(f" │ ❌ {m['archivo']} (sin datos utiles, eliminado)") + + e['metadatos_adjuntos'] = metadatos + procesados.append(e) + + except Exception as ex: + print(f" │ {ic(False, '', f'Error: {ex}')}") + file_manager.limpiar_carpeta_descarga(mail.ruta_descarga) + + if llm_processor.CONTADOR_LLM > 0: + print(f" 🤖 LLM invocado {llm_processor.CONTADOR_LLM} vez/veces en esta ejecucion (modelo: {llm_processor.MODEL})") + + return procesados + +# ======================================================================== +def guardar_json(todos_procesados): + if not todos_procesados: + return + + output_dir = "output_json" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + json_path = os.path.join(output_dir, f"{timestamp}.json") + + with open(json_path, "w", encoding="utf-8") as f: + json.dump(todos_procesados, f, ensure_ascii=False, indent=4) + + print(f"\n💾 Resultados exportados a: {json_path}") + +# ======================================================================== +def signal_handler(signum, frame): + global _running + print("\n\n⏹️ Senal de parada recibida. Deteniendo...") + _running = False + +# ======================================================================== +def main(): + global _running + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + log_path = setup_logger() + limpiar_logs_antiguos(dias=30) + + print("=" * 60) + print("🚀 DAEMON DESCARGA ADJUNTOS CORREOS CPE") + print("=" * 60) + print(f"\n📅 Dias activos: {DIAS_ACTIVOS} (0=lun, 6=dom)") + print(f"⏰ Horarios:") + for hi, mi, hf, mf, intervalo, modo in HORARIOS: + print(f" {hi:02d}:{mi:02d} - {hf:02d}:{mf:02d} | cada {intervalo}s | {modo}") + print(f"\n📄 Log: {log_path}") - # Inicializar Base de Datos database_utilities.init_db() - # Calcular fechas hoy = datetime.now() - ayer = hoy - timedelta(days=1) - + ayer = hoy - timedelta(days=5) fecha_inicio = ayer.strftime("%Y-%m-%d") fecha_fin = hoy.strftime("%Y-%m-%d") - print(f"Rango de búsqueda: {fecha_inicio} al {fecha_fin}") + outlook_token = None + fecha_obtencion_token = None - try: - print("Intentando obtener token de acceso...") - outlook_token = outlook_utilities.get_access_token() - print("✅ Token obtenido exitosamente.") - print(f"Token prefix: {outlook_token[:15]}...") - except Exception as e: - print(f"❌ Error: {e}") - sys.exit(1) + while _running: + ahora = datetime.now() - todos_procesados = [] + if ahora.weekday() not in DIAS_ACTIVOS: + espera = segundos_hasta_proximo_dia_activo(ahora) + prox = ahora + timedelta(seconds=espera) + print(f"\n⏸️ Dia no activo. Proximo inicio: {prox.strftime('%a %Y-%m-%d %H:%M:%S')}") + for _ in range(int(espera)): + if not _running: + break + time.sleep(1) + continue - for mail in CONFIG.mails: - print("\n" + "="*60) - print(f"[MAIL] {mail.email}") + intervalo_info = obtener_intervalo(ahora) + if not intervalo_info: + espera = segundos_hasta_proximo_inicio(ahora) + prox = ahora + timedelta(seconds=espera) + print(f"\n⏸️ Fuera de horario. Proximo inicio: {prox.strftime('%H:%M:%S')}") + for _ in range(int(espera)): + if not _running: + break + time.sleep(1) + continue - emails = outlook_utilities.get_emails(mail.email, outlook_token, fecha_inicio, fecha_fin) - print(f"Se encontraron {len(emails)} correos.") + intervalo, modo = intervalo_info - # Filtrar y guardar usando el servicio de emails - emails_a_procesar = email_services.filtrar_correos_nuevos(emails) + print(f"\n{'#'*60}") + print(f"# 🔄 CICLO: {ahora.strftime('%Y-%m-%d %H:%M:%S')} | modo={modo} | intervalo={intervalo}s") + print(f"{'#'*60}") - if emails_a_procesar: - print(f"Descargando adjuntos en: {mail.ruta_descarga}") - emails_finales = [] # Lista definitiva de correos con adjuntos válidos + try: + outlook_token = obtener_token(outlook_token, fecha_obtencion_token) + if not fecha_obtencion_token: + fecha_obtencion_token = datetime.now() + except Exception as e: + print(f" {ic(False, '', f'Error obteniendo token: {e}')}") + time.sleep(60) + continue - for e in emails_a_procesar: - try: - # Guardar lista original para el log - archivos_orig = outlook_utilities.download_attachments( - mail.email, - e['id'], - outlook_token, - mail.ruta_descarga - ) - - if archivos_orig: - # Encabezado del correo con fecha - print(f"\n📬 [{e['fecha']}] {e['subject'][:80]}...") - - # --- SECCIÓN 1: ADJUNTOS ORIGINALES --- - print(f" ├── 📂 Adjuntos originales:") - for idx, a in enumerate(archivos_orig): - prefijo = " │ └── " if idx == len(archivos_orig)-1 else " │ ├── " - print(f"{prefijo}{a}") + todos_procesados = [] - # 1. Procesar adjuntos (unzip + filtro) - # Usamos una copia de la lista original - archivos_unzipped = email_services.procesar_adjuntos_email(e, archivos_orig.copy(), mail.ruta_descarga) - - if archivos_unzipped: - # 2. Extraer metadatos iniciales (antes del renombrado) - metadatos = document_processor.procesar_adjuntos_enriquecidos(e, mail.ruta_descarga) - e['metadatos_adjuntos'] = metadatos - - # 3. Aplicar lógica de agrupación y renombrado - # Esto modifica e['metadatos_adjuntos'] in-place - agrupacion = file_manager.procesar_renombrado_agrupado(e, mail.ruta_descarga) - e['agrupacion_archivos'] = agrupacion - - # --- SECCIÓN 2: PROCESO --- - print(f" ├── ⚙️ Proceso:") - logs_proceso = [] - - # Detectar ZIPs procesados - zips = [a for a in archivos_orig if a.lower().endswith('.zip')] - for z in zips: - logs_proceso.append(f"📦 Descomprimido: {z}") - - # Detectar Renombrados (si el nombre final no está en los unzipped filtrados) - for m in e['metadatos_adjuntos']: - if m['archivo'] not in archivos_unzipped: - logs_proceso.append(f"🏷️ Renombrado a: {m['archivo']}") + for mail in CONFIG.mails: + if not _running: + break + procesados = procesar_cuenta(mail, outlook_token, fecha_inicio, fecha_fin) + if procesados: + sincronizar_a_smb(mail.ruta_final, mail.smb_folder) + print(f"\n✅ {mail.email}: {len(procesados)} correos procesados.") + else: + print(f"\nℹ️ {mail.email}: sin correos para procesar.") + todos_procesados.extend(procesados) - if not logs_proceso: - print(f" │ └── Sin cambios especiales") - else: - for idx, log in enumerate(logs_proceso): - prefijo = " │ └── " if idx == len(logs_proceso)-1 else " │ ├── " - print(f"{prefijo}{log}") + guardar_json(todos_procesados) - # 4. Mover archivos a carpeta de resultados final - file_manager.mover_archivos_a_resultado(e, mail.ruta_descarga, mail.ruta_final) - - # --- SECCIÓN 3: RESULTADO FINAL --- - print(f" └── 📄 Resultado Final:") - for idx, m in enumerate(e['metadatos_adjuntos']): - prefijo = " └── " if idx == len(e['metadatos_adjuntos'])-1 else " ├── " - icono = "📕" if m['extension'] == ".pdf" else "🌐" - info_extra = f"[{m['tipo']}] {m['serie_numero']} - RUC: {m['ruc']}" - print(f"{prefijo}{icono} {m['archivo']} -> {info_extra}") - - emails_finales.append(e) - else: - print(f" └── ℹ️ Descartado (sin archivos PDF/XML válidos).") - - except Exception as ex: - print(f" ❌ Error procesando {e['id'][:10]}: {ex}") + print(f"\n⏳ Esperando {intervalo}s hasta proximo ciclo...") + for _ in range(intervalo): + if not _running: + break + time.sleep(1) - # 5. Sincronizar a SMB al terminar de procesar TODOS los correos de esta cuenta - if emails_finales: - import smb_utils - smb_utils.sincronizar_a_smb(mail.ruta_final, mail.smb_folder) - - # 6. Limpiar carpeta de descarga original - file_manager.limpiar_carpeta_descarga(mail.ruta_descarga) + print("\n👋 Daemon detenido.") - # Actualizamos el listado con los que realmente tienen contenido útil - emails_a_procesar = emails_finales - print(f"\n✅ Proceso completado para {mail.email}. {len(emails_a_procesar)} correos listos para procesar.") - - # Acumular para el guardado final en JSON - todos_procesados.extend(emails_a_procesar) - - # Guardar resultados globales en JSON - if todos_procesados: - output_dir = "output_json" - if not os.path.exists(output_dir): - os.makedirs(output_dir) - - timestamp_json = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - json_path = os.path.join(output_dir, f"{timestamp_json}.json") - - with open(json_path, "w", encoding="utf-8") as f: - json.dump(todos_procesados, f, ensure_ascii=False, indent=4) - - print("\n" + "="*60) - print(f"💾 Resultados exportados a: {json_path}") - - fin_ejecucion = datetime.now() - duracion = fin_ejecucion - inicio_ejecucion - print("\n" + "="*60) - print(f"🏁 FIN DE EJECUCIÓN: {fin_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"⏱️ Duración total: {str(duracion).split('.')[0]}") # Sin microsegundos - print("="*60) +# ======================================================================== +if __name__ == "__main__": + main() diff --git a/outlook_utilities.py b/outlook_utilities.py index 9473b4b..a61b10d 100644 --- a/outlook_utilities.py +++ b/outlook_utilities.py @@ -8,7 +8,10 @@ from config_loader import CONFIG warnings.filterwarnings("ignore") -######################################################## +# Diferencia horaria respecto a UTC (Perú: UTC-5) +UTC_OFFSET_HOURS = -5 + +# ======================================================================== def get_access_token(): """ Obtiene un token de acceso de Microsoft Graph usando las credenciales @@ -37,7 +40,7 @@ def get_access_token(): correlation_id = result.get("correlation_id") raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})") -######################################################## +# ======================================================================== def get_emails(user_email, token, fecha_inicio, fecha_fin): """ Obtiene los correos de una cuenta específica en un rango de fechas. @@ -48,8 +51,8 @@ def get_emails(user_email, token, fecha_inicio, fecha_fin): "Content-Type": "application/json" } - dt_inicio_utc = datetime.strptime(fecha_inicio, "%Y-%m-%d") + timedelta(hours=5) - dt_fin_utc = datetime.strptime(fecha_fin, "%Y-%m-%d") + timedelta(days=1, hours=5) + dt_inicio_utc = datetime.strptime(fecha_inicio, "%Y-%m-%d") + timedelta(hours=-UTC_OFFSET_HOURS) + dt_fin_utc = datetime.strptime(fecha_fin, "%Y-%m-%d") + timedelta(days=1, hours=-UTC_OFFSET_HOURS) filter_query = ( f"receivedDateTime ge {dt_inicio_utc.strftime('%Y-%m-%dT%H:%M:%SZ')} and " @@ -82,7 +85,7 @@ def get_emails(user_email, token, fecha_inicio, fecha_fin): for msg in messages: utc_str = msg.get("receivedDateTime") dt_utc = datetime.fromisoformat(utc_str.replace("Z", "+00:00")) - dt_local = dt_utc - timedelta(hours=5) + dt_local = dt_utc + timedelta(hours=UTC_OFFSET_HOURS) from_obj = msg.get("from", {}) sender = from_obj.get("emailAddress", {}).get("address", "Desconocido") @@ -100,7 +103,7 @@ def get_emails(user_email, token, fecha_inicio, fecha_fin): return all_mails -######################################################## +# ======================================================================== def download_attachments(user_email, message_id, token, download_path): """ Descarga los adjuntos de un mensaje específico y los guarda en la ruta indicada. @@ -137,7 +140,7 @@ def download_attachments(user_email, message_id, token, download_path): return downloaded_files -######################################################## +# ======================================================================== if __name__ == "__main__": try: print("Intentando obtener token de acceso...") @@ -162,4 +165,5 @@ if __name__ == "__main__": print(f"Descargados: {archivos}") break except Exception as e: - print(f"❌ Error: {e}") \ No newline at end of file + print(f"❌ Error: {e}") +# ======================================================================== \ No newline at end of file diff --git a/pdf_utilities.py b/pdf_utilities.py index 0ac9cc9..2bd96ab 100644 --- a/pdf_utilities.py +++ b/pdf_utilities.py @@ -1,8 +1,9 @@ import pdfplumber import re import os +import llm_processor -######################################################## +# ======================================================================== def extraer_texto_pdf(ruta_pdf): """Extrae todo el texto de un PDF usando pdfplumber.""" texto_completo = "" @@ -15,7 +16,7 @@ def extraer_texto_pdf(ruta_pdf): print(f" ⚠️ Error leyendo PDF {ruta_pdf}: {e}") return "" -######################################################## +# ======================================================================== def extraer_ruc(texto): """Busca un RUC (11 dígitos, empieza con 10 o 20) en el texto.""" # Intentamos buscar patrones de RUC: RUC 2060... o R.U.C. 2060... @@ -30,54 +31,139 @@ def extraer_ruc(texto): return match.group(1) return "N/A" -######################################################## +# ======================================================================== +def _flexible(palabra): + """Convierte una palabra en un patrón regex que tolera espacios entre caracteres. + Ej: FACTURA -> F\\s*A\\s*C\\s*T\\s*U\\s*R\\s*A + """ + return '\\s*'.join(palabra) + + def extraer_tipo_documento(texto): - """Identifica el tipo de documento SUNAT basándose en el texto usando regex multilínea.""" + """Identifica el tipo de documento SUNAT basándose en el texto usando regex multilínea. + Soporta texto con espacios entre caracteres (ej: 'F A C T U R A').""" t = texto.upper() - + patrones = { - "01": [r'FACTURA\s+(ELECTR[OÓ]NICA|DE\s+VENTA)', r'FACTURA'], - "03": [r'BOLETA\s+DE\s+VENTA', r'BOLETA\s+ELECTR[OÓ]NICA'], - "07": [r'NOTA\s+DE\s+CR[EÉ]DITO'], - "08": [r'NOTA\s+DE\s+D[EÉ]BITO'], - "14": [r'RECIBO\s+POR\s+HONORARIOS'], - "09": [r'GU[IÍ]A\s+DE\s+REMISI[OÓ]N'] + "01": [ + rf'{_flexible("FACTURA")}\s+({_flexible("ELECTRÓNICA")}|{_flexible("ELECTRONICA")}|DE\s+VENTA)', + rf'{_flexible("FACTURA")}', + ], + "03": [ + rf'{_flexible("BOLETA")}\s+DE\s+{_flexible("VENTA")}', + rf'{_flexible("BOLETA")}\s+{_flexible("ELECTRÓNICA")}', + rf'{_flexible("BOLETA")}\s+{_flexible("ELECTRONICA")}', + ], + "07": [ + rf'{_flexible("NOTA")}\s+DE\s+{_flexible("CRÉDITO")}', + rf'{_flexible("NOTA")}\s+DE\s+{_flexible("CREDITO")}', + ], + "08": [ + rf'{_flexible("NOTA")}\s+DE\s+{_flexible("DÉBITO")}', + rf'{_flexible("NOTA")}\s+DE\s+{_flexible("DEBITO")}', + ], + "14": [ + rf'{_flexible("RECIBO")}\s+POR\s+{_flexible("HONORARIOS")}', + ], + "09": [ + rf'{_flexible("GUÍA")}\s+DE\s+{_flexible("REMISIÓN")}', + rf'{_flexible("GUIA")}\s+DE\s+{_flexible("REMISION")}', + ], } - + for tipo, regex_list in patrones.items(): for p in regex_list: if re.search(p, t, re.IGNORECASE | re.MULTILINE): return tipo - + return "99" -######################################################## +# ======================================================================== def extraer_serie_numero(texto): - """Busca la serie y número (ej. F001-123 o F001-0000123).""" - # Patrón común: [FBE][A-Z0-9]{3}-\d+ - patron = re.search(r'\b([FBE][A-Z0-9]{3})\s*-\s*(\d+)\b', texto) - if patron: - return f"{patron.group(1)}-{patron.group(2)}" + """Busca la serie y número (ej. F001-123, E001-0000123, EG07-2367).""" + serie_patrones = [ + # Formato con guión: letras+dígitos - número + r'\b([A-Z]{1,4}\d{2,4})\s*-\s*(\d+)\b', + # Fallback sin guión + r'([A-Z]{1,4}\d{2,4})\s+(\d{1,10})', + # Serie numérica corta sin letras + r'\b(\d{3})\s*-\s*(\d+)\b' + ] - # Fallback para casos donde no hay guión o hay espacios raros - patron_alt = re.search(r'([FBE][A-Z0-9]{3})\s+(\d{1,10})', texto) - if patron_alt: - return f"{patron_alt.group(1)}-{patron_alt.group(2)}" + for patron in serie_patrones: + match = re.search(patron, texto) + if match: + return f"{match.group(1)}-{match.group(2)}" return "No encontrado" -######################################################## +# ======================================================================== +def _necesita_llm(resultado): + """True si algún campo clave no se encontró con regex.""" + return ( + resultado['ruc'] in ("N/A", "Desconocido") or + resultado['tipo_doc'] == "99" or + resultado['id'] in ("No encontrado", "N/A") + ) + + +def _campos_rellenados(regex_original, regex_result): + """Cuenta cuántos campos cambió el LLM respecto al regex original.""" + count = 0 + if regex_original['ruc'] in ("N/A", "Desconocido") and regex_result['ruc'] not in ("N/A", "Desconocido"): + count += 1 + if regex_original['tipo_doc'] == "99" and regex_result['tipo_doc'] != "99": + count += 1 + if regex_original['id'] in ("No encontrado", "N/A") and regex_result['id'] not in ("No encontrado", "N/A"): + count += 1 + return count + + +def _merge_llm(regex_result, llm_data): + """Rellena campos vacíos del regex con datos del LLM sin pisar lo ya encontrado.""" + if regex_result['ruc'] in ("N/A", "Desconocido"): + ruc = llm_data.get('ruc', '') + if re.match(r'^(10|20)\d{9}$', str(ruc)): + regex_result['ruc'] = ruc + + if regex_result['tipo_doc'] == "99": + tipo = str(llm_data.get('tipo', '99')) + tipo_codes = {"01", "02", "03", "07", "08", "09", "14"} + if tipo in tipo_codes: + regex_result['tipo_doc'] = tipo + + if regex_result['id'] in ("No encontrado", "N/A"): + serie = str(llm_data.get('serie', '')) + numero = str(llm_data.get('numero', '')) + if serie and numero: + regex_result['id'] = f"{serie}-{numero}" + + return regex_result + + def obtener_datos_pdf(ruta_pdf): - """Orquestador para extraer metadata de un PDF.""" + """Orquestador para extraer metadata de un PDF. + Primero intenta con regex; si faltan campos, usa LLM como fallback.""" texto = extraer_texto_pdf(ruta_pdf) if not texto: return {"valido": False, "error": "No se pudo extraer texto"} - return { + resultado = { "valido": True, "tipo_doc": extraer_tipo_documento(texto), "ruc": extraer_ruc(texto), - "id": extraer_serie_numero(texto) + "id": extraer_serie_numero(texto), + "llm_usado": False, + "llm_respuesta": None } -######################################################## \ No newline at end of file + if _necesita_llm(resultado): + resultado["llm_usado"] = True + llm_data = llm_processor.extraer_con_llm(texto) + resultado["llm_respuesta"] = dict(llm_data) if llm_data else None + if llm_data: + antes = dict(resultado) + resultado = _merge_llm(resultado, llm_data) + + return resultado +# ======================================================================== \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7e2e112..b5c0b0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests msal -pdfplumber \ No newline at end of file +pdfplumber +pytest \ No newline at end of file diff --git a/scheduler.json b/scheduler.json new file mode 100644 index 0000000..cf67aa1 --- /dev/null +++ b/scheduler.json @@ -0,0 +1,9 @@ +{ + "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] +} diff --git a/smb_manager.py b/smb_manager.py new file mode 100644 index 0000000..4ebfc54 --- /dev/null +++ b/smb_manager.py @@ -0,0 +1,144 @@ +import os +import platform +import subprocess +from config_loader import CONFIG + +# ======================================================================== +def get_current_os(): + if os.path.exists("/.dockerenv"): + return "docker" + + system = platform.system().lower() + if system == "darwin": + return "mac" + if system == "windows": + return "windows" + return "linux" + +# ======================================================================== +def get_mount_point(): + return CONFIG.smb.mount_points.get(get_current_os(), "") + + +def esta_montado(mount_point): + if not mount_point: + return False + if get_current_os() == "windows": + return os.path.exists(mount_point) + return os.path.ismount(mount_point) or os.path.exists( + os.path.join(mount_point, ".mounted_check") + ) + +# ======================================================================== +def montar_smb(): + mount_point = get_mount_point() + if not mount_point: + print(" ⚠️ No hay punto de montaje configurado para este SO.") + return False + + if esta_montado(mount_point): + return True + + current_os = get_current_os() + + if current_os != "windows" and not os.path.exists(mount_point): + os.makedirs(mount_point) + + smb = CONFIG.smb + + try: + if current_os == "mac": + cmd = ( + f"mount_smbfs //'{smb.username}':'{smb.password}'" + f"@{smb.server}/{smb.share} {mount_point}" + ) + subprocess.run(cmd, shell=True, check=True, capture_output=True) + elif current_os == "linux": + cmd = ( + f"sudo mount -t cifs //{smb.server}/{smb.share} {mount_point}" + f" -o username={smb.username},password={smb.password}" + ) + subprocess.run(cmd, shell=True, check=True, capture_output=True) + + elif current_os == "docker": + print("ℹ️ Docker: SMB debe estar montado por el host") + return os.path.exists(mount_point) + + elif current_os == "windows": + ruta_smb = f"\\\\{smb.server}\\{smb.share}" + cmd = [ + "net", "use", mount_point, ruta_smb, + smb.password, f"/user:{smb.username}", "/persistent:no", + ] + subprocess.run(cmd, check=True, capture_output=True) + + print(f" ✅ SMB montado en: {mount_point}") + return True + except Exception as e: + print(f" ⚠️ Error montando SMB: {e}") + return False + +# ======================================================================== +def sincronizar_a_smb(ruta_local, subfolder_cuenta): + if not os.path.isdir(ruta_local): + print(f" ⚠️ La carpeta local no existe: {ruta_local}") + return False + + if not montar_smb(): + print(" ❌ No se pudo sincronizar: SMB no montado.") + return False + + mount_point = get_mount_point() + current_os = get_current_os() + + if current_os == "windows" and mount_point.endswith(":") and len(mount_point) == 2: + mount_point += "\\" + + subfolder = subfolder_cuenta.lstrip("/") + ruta_destino = os.path.normpath(os.path.join(mount_point, subfolder)) + + if not os.path.exists(ruta_destino): + os.makedirs(ruta_destino) + print(f" 📁 Carpeta creada en SMB: {ruta_destino}") + + source = os.path.abspath(ruta_local) + destination = os.path.abspath(ruta_destino) + + print(f" 🚀 Sincronizando {current_os.upper()}: {source} -> {destination}") + + try: + if current_os in ("mac", "linux", "docker"): + # Solo sincronizar PDF y XML + cmd = ( + f"find {source} -maxdepth 1 \\( -name '*.pdf' -o -name '*.xml' \\) " + f"-exec rsync -av --remove-source-files {{}} {destination}/ \\;" + ) + subprocess.run(cmd, shell=True, check=True, capture_output=True, text=True) + # Limpiar temporales restantes (json, zip) + subprocess.run( + f"find {source} -type f \\( -name '*.json' -o -name '*.zip' \\) -delete", + shell=True, check=True, capture_output=True, text=True, + ) + + elif current_os == "windows": + import shutil + for archivo in os.listdir(source): + if not archivo.lower().endswith((".pdf", ".xml")): + continue + src_path = os.path.join(source, archivo) + dst_path = os.path.join(destination, archivo) + if os.path.isfile(src_path): + if os.path.exists(dst_path): + os.remove(dst_path) + shutil.move(src_path, dst_path) + + for archivo in os.listdir(source): + if archivo.lower().endswith((".json", ".zip")): + os.remove(os.path.join(source, archivo)) + + print(f" ✅ Sincronización completada.") + return True + except Exception as e: + print(f" ❌ Error en sincronización: {e}") + return False +# ======================================================================== \ No newline at end of file diff --git a/smb_utils.py b/smb_utils.py deleted file mode 100644 index 258419e..0000000 --- a/smb_utils.py +++ /dev/null @@ -1,126 +0,0 @@ -import os -import platform -import subprocess -from config_loader import CONFIG - -######################################################## -def get_current_os(): - system = platform.system().lower() - if system == "darwin": return "mac" - return system - -######################################################## -def get_mount_point(): - current_os = get_current_os() - return CONFIG.smb.mount_points.get(current_os) - -######################################################## -def esta_montado(mount_point): - """Verifica si el punto de montaje ya está activo.""" - if get_current_os() == "windows": - return os.path.exists(mount_point) - return os.path.ismount(mount_point) or os.path.exists(os.path.join(mount_point, ".mounted_check")) # Simplificado - -######################################################## -def montar_smb(): - """Intenta montar la unidad SMB según el SO.""" - mount_point = get_mount_point() - current_os = get_current_os() - - if not mount_point: - print(" ⚠️ No hay punto de montaje configurado para este SO.") - return False - - if esta_montado(mount_point): - # print(f" ✅ SMB ya montado en: {mount_point}") - return True - - # En Windows no creamos la carpeta del punto de montaje (es una unidad virtual) - if current_os != "windows" and not os.path.exists(mount_point): - os.makedirs(mount_point) - - smb = CONFIG.smb - current_os = get_current_os() - - try: - if current_os == "mac": - # mount_smbfs //user:pass@server/share mount_point - cmd = f"mount_smbfs //'{smb.username}':'{smb.password}'@{smb.server}/{smb.share} {mount_point}" - subprocess.run(cmd, shell=True, check=True, capture_output=True) - elif current_os == "linux": - # mount -t cifs //server/share mount_point -o username=user,password=pass - cmd = f"sudo mount -t cifs //{smb.server}/{smb.share} {mount_point} -o username={smb.username},password={smb.password}" - subprocess.run(cmd, shell=True, check=True, capture_output=True) - elif current_os == "windows": - # net use S: \\server\share /user:user pass - ruta_smb = f"\\\\{smb.server}\\{smb.share}" - cmd = [ - "net", "use", mount_point, ruta_smb, - smb.password, f"/user:{smb.username}", "/persistent:no" - ] - subprocess.run(cmd, check=True, capture_output=True) - - print(f" ✅ SMB montado exitosamente en: {mount_point}") - return True - except Exception as e: - print(f" ⚠️ Error montando SMB: {e}") - return False - -######################################################## -def sincronizar_a_smb(ruta_local, smb_subfolder): - """Sincroniza archivos hacia el recurso SMB montado según el SO.""" - if not montar_smb(): - print(" ❌ No se pudo sincronizar: SMB no montado.") - return False - - mount_point = get_mount_point() - current_os = get_current_os() - - # Normalizar punto de montaje para Windows (asegurar S:\ en lugar de S:) - if current_os == "windows" and mount_point.endswith(":") and len(mount_point) == 2: - mount_point += "\\" - - subfolder = smb_subfolder.lstrip('/') - ruta_destino = os.path.normpath(os.path.join(mount_point, subfolder)) - - if not os.path.exists(ruta_destino): - os.makedirs(ruta_destino) - print(f" 📁 Carpeta de destino creada en SMB: {ruta_destino}") - - source = os.path.abspath(ruta_local) - destination = os.path.abspath(ruta_destino) - - print(f" 🚀 Sincronizando a SMB ({current_os.upper()}): {ruta_destino}...") - - try: - if current_os in ["mac", "linux"]: - # Usar rsync en sistemas Unix - # El / al final del source indica copiar contenido - cmd = ["rsync", "-av", "--remove-source-files", source + "/", destination + "/"] - subprocess.run(cmd, check=True, capture_output=True, text=True) - - elif current_os == "windows": - # Usar shutil de Python (sin dependencias externas) - import shutil - for archivo in os.listdir(source): - src_path = os.path.join(source, archivo) - dst_path = os.path.join(destination, archivo) - - if os.path.isfile(src_path): - # shutil.move maneja copia y borrado incluso entre discos diferentes - if os.path.exists(dst_path): - os.remove(dst_path) - shutil.move(src_path, dst_path) - elif os.path.isdir(src_path): - # Para carpetas, usamos copytree + rmtree o move - if os.path.exists(dst_path): - shutil.rmtree(dst_path) - shutil.move(src_path, dst_path) - - print(f" ✅ Sincronización completada.") - return True - except Exception as e: - print(f" ❌ Error en la sincronización: {e}") - return False - -######################################################## diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2c30c2d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,110 @@ +import pytest +import os +import tempfile +import xml.etree.ElementTree as ET + + +FACTURA_XML = ''' + + F001-00005103 + 01 + + + + 20549758054 + + + + + + + IGV + + + +''' + +CREDIT_NOTE_XML = ''' + + B001-00001234 + 07 + + + + 20123456789 + + + +''' + +CDR_XML = ''' + + R-2025-00000123 + + + 20549758054 + + +''' + +RECIBO_XML = ''' + + R001-00000456 + 02 + + 10456789012 + + + + + RET 4TA + + + +''' + + +@pytest.fixture +def temp_dir(): + with tempfile.TemporaryDirectory() as tmpdir: + yield tmpdir + + +@pytest.fixture +def factura_xml_path(temp_dir): + path = os.path.join(temp_dir, "F001-00005103.xml") + with open(path, "w", encoding="utf-8") as f: + f.write(FACTURA_XML) + return path + + +@pytest.fixture +def credito_xml_path(temp_dir): + path = os.path.join(temp_dir, "B001-00001234.xml") + with open(path, "w", encoding="utf-8") as f: + f.write(CREDIT_NOTE_XML) + return path + + +@pytest.fixture +def cdr_xml_path(temp_dir): + path = os.path.join(temp_dir, "R-2025-00000123.xml") + with open(path, "w", encoding="utf-8") as f: + f.write(CDR_XML) + return path + + +@pytest.fixture +def recibo_xml_path(temp_dir): + path = os.path.join(temp_dir, "R001-00000456.xml") + with open(path, "w", encoding="utf-8") as f: + f.write(RECIBO_XML) + return path diff --git a/tests/test_database_utilities.py b/tests/test_database_utilities.py new file mode 100644 index 0000000..d7667fe --- /dev/null +++ b/tests/test_database_utilities.py @@ -0,0 +1,73 @@ +import os +import sys +import pytest +import tempfile +import sqlite3 + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Forzar uso de DB temporal para no contaminar la real +@pytest.fixture(autouse=True) +def override_db_path(monkeypatch, tmp_path): + db_path = str(tmp_path / "test_emails.db") + monkeypatch.setattr("database_utilities.DB_NAME", db_path) + yield + if os.path.exists(db_path): + os.remove(db_path) + + +from database_utilities import init_db, save_emails, get_existing_ids, delete_email_by_id + + +class TestInitDb: + def test_crea_tabla(self): + init_db() + conn = sqlite3.connect("test_emails.db" if False else ":memory:") + # No podemos verificar la DB real porque el path se sobreescribió + # Solo verificar que no lanza excepción + assert True + + +class TestSaveAndGetEmails: + def test_save_and_retrieve(self): + init_db() + emails = [ + {"id": "id1", "fecha": "2026-01-01", "sender": "a@b.com", + "subject": "Test", "has_attach": True}, + {"id": "id2", "fecha": "2026-01-02", "sender": "c@d.com", + "subject": "Test2", "has_attach": False}, + ] + assert save_emails(emails) > 0 + assert get_existing_ids(["id1", "id2", "id3"]) == ["id1", "id2"] + + def test_duplicados_ignorados(self): + init_db() + emails = [ + {"id": "id1", "fecha": "2026-01-01", "sender": "a@b.com", + "subject": "Test", "has_attach": True}, + ] + save_emails(emails) + # Insertar el mismo id otra vez + save_emails(emails) + assert get_existing_ids(["id1"]) == ["id1"] + + def test_lista_vacia(self): + assert save_emails([]) == 0 + assert get_existing_ids([]) == [] + + +class TestDeleteEmail: + def test_delete_existing(self): + init_db() + save_emails([{"id": "id1", "fecha": "2026-01-01", "sender": "a@b.com", + "subject": "Test", "has_attach": True}]) + assert delete_email_by_id("id1") is True + assert get_existing_ids(["id1"]) == [] + + def test_delete_nonexistent(self): + init_db() + assert delete_email_by_id("no_existe") is False + + def test_delete_empty_id(self): + assert delete_email_by_id("") is False + assert delete_email_by_id(None) is False diff --git a/tests/test_document_processor.py b/tests/test_document_processor.py new file mode 100644 index 0000000..a7b64f9 --- /dev/null +++ b/tests/test_document_processor.py @@ -0,0 +1,98 @@ +import os +import sys +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from document_processor import ( + formatear_serie_numero, + generar_nombre_propuesto, + obtener_datos_xml, + TIPOS_SUNAT, +) + + +class TestFormatearSerieNumero: + def test_formato_estandar(self): + assert formatear_serie_numero("F001-00005103") == "F001-5103" + + def test_sin_ceros(self): + assert formatear_serie_numero("F001-123") == "F001-123" + + def test_con_letras_en_numero(self): + assert formatear_serie_numero("E001-ABC") == "E001-ABC" + + def test_sin_guion(self): + assert formatear_serie_numero("F001") == "F001" + + def test_cdr_id(self): + assert formatear_serie_numero("R-2025-00000123") == "R-2025-00000123" + + def test_na(self): + assert formatear_serie_numero("N/A") == "N/A" + + def test_no_encontrado(self): + assert formatear_serie_numero("No encontrado") == "No encontrado" + + +class TestGenerarNombrePropuesto: + def test_factura(self): + m = {"tipo": "01", "ruc": "20549758054", "serie_numero": "F001-5103"} + assert generar_nombre_propuesto(m) == "20549758054-01-F001-5103" + + def test_cdr(self): + m = {"tipo": "CDR", "ruc": "20549758054", "serie_numero": "R-2025-123"} + assert generar_nombre_propuesto(m) == "20549758054-CDR-R-2025-123" + + def test_credito(self): + m = {"tipo": "07", "ruc": "20123456789", "serie_numero": "B001-1234"} + assert generar_nombre_propuesto(m) == "20123456789-07-B001-1234" + + def test_tipo_99_no_renombra(self): + m = {"tipo": "99", "ruc": "20549758054", "serie_numero": "F001-5103"} + assert generar_nombre_propuesto(m) is None + + def test_ruc_desconocido_no_renombra(self): + m = {"tipo": "01", "ruc": "Desconocido", "serie_numero": "F001-5103"} + assert generar_nombre_propuesto(m) is None + + def test_ruc_na_no_renombra(self): + m = {"tipo": "01", "ruc": "N/A", "serie_numero": "F001-5103"} + assert generar_nombre_propuesto(m) is None + + +class TestObtenerDatosXml: + def test_factura(self, factura_xml_path): + res = obtener_datos_xml(factura_xml_path) + assert res["valido"] is True + assert res["tipo_xml"] == "01" + assert res["ruc"] == "20549758054" + assert res["id"] == "F001-00005103" + + def test_credito(self, credito_xml_path): + res = obtener_datos_xml(credito_xml_path) + assert res["valido"] is True + assert res["tipo_xml"] == "07" + assert res["ruc"] == "20123456789" + assert res["id"] == "B001-00001234" + + def test_cdr(self, cdr_xml_path): + res = obtener_datos_xml(cdr_xml_path) + assert res["valido"] is True + assert res["tipo_xml"] == "CDR" + assert res["id"] == "R-2025-00000123" + + def test_recibo_honorarios(self, recibo_xml_path): + res = obtener_datos_xml(recibo_xml_path) + assert res["valido"] is True + assert res["tipo_xml"] == "02" + assert res["id"] == "R001-00000456" + + def test_xml_inexistente(self): + res = obtener_datos_xml("/ruta/inexistente.xml") + assert res["valido"] is False + + def test_tipos_sunat_incluye_cdr(self): + assert "CDR" in TIPOS_SUNAT + assert "01" in TIPOS_SUNAT + assert "02" in TIPOS_SUNAT diff --git a/tests/test_email_services.py b/tests/test_email_services.py new file mode 100644 index 0000000..8d111b3 --- /dev/null +++ b/tests/test_email_services.py @@ -0,0 +1,108 @@ +import os +import sys +import pytest +from unittest.mock import patch, MagicMock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from email_services import ( + filtrar_correos_nuevos, + descomprimir_adjuntos, + filtrar_archivos_validos, + procesar_adjuntos_email, +) + + +class TestFiltrarArchivosValidos: + def test_solo_pdf(self): + archivos = ["doc.pdf", "imagen.jpg", "texto.txt"] + assert filtrar_archivos_validos(archivos) == ["doc.pdf"] + + def test_solo_xml(self): + archivos = ["doc.xml", "imagen.jpg", "texto.txt"] + assert filtrar_archivos_validos(archivos) == ["doc.xml"] + + def test_pdf_y_xml(self): + archivos = ["doc.pdf", "doc.xml", "imagen.jpg"] + assert filtrar_archivos_validos(archivos) == ["doc.pdf", "doc.xml"] + + def test_sin_validos(self): + archivos = ["imagen.jpg", "texto.txt", "archivo.zip"] + assert filtrar_archivos_validos(archivos) == [] + + def test_case_insensitive(self): + archivos = ["DOC.PDF", "Doc.Xml", "IMAGEN.JPG"] + assert filtrar_archivos_validos(archivos) == ["DOC.PDF", "Doc.Xml"] + + +class TestDescomprimirAdjuntos: + def test_sin_zips(self, temp_dir): + archivos = ["a.pdf", "b.xml"] + resultado = descomprimir_adjuntos(archivos, temp_dir) + assert resultado == ["a.pdf", "b.xml"] + + def test_zip_inexistente(self, temp_dir): + archivos = ["nope.zip"] + resultado = descomprimir_adjuntos(archivos, temp_dir) + assert resultado == ["nope.zip"] + + +class TestFiltrarCorreosNuevos: + @patch("email_services.database_utilities.get_existing_ids") + @patch("email_services.database_utilities.save_emails") + def test_retorna_todos_los_nuevos(self, mock_save, mock_get): + mock_get.return_value = [] + emails = [ + {"id": "1", "has_attach": True}, + {"id": "2", "has_attach": False}, + ] + resultado = filtrar_correos_nuevos(emails) + # Debe retornar TODOS los nuevos (con y sin adjuntos) + assert len(resultado) == 2 + mock_save.assert_called_once() + + @patch("email_services.database_utilities.get_existing_ids") + @patch("email_services.database_utilities.save_emails") + def test_filtra_existentes(self, mock_save, mock_get): + mock_get.return_value = ["1"] + emails = [ + {"id": "1", "has_attach": True}, + {"id": "2", "has_attach": True}, + ] + resultado = filtrar_correos_nuevos(emails) + assert len(resultado) == 1 + assert resultado[0]["id"] == "2" + + @patch("email_services.database_utilities.get_existing_ids") + def test_emails_vacios(self, mock_get): + assert filtrar_correos_nuevos([]) == [] + mock_get.assert_not_called() + + +class TestProcesarAdjuntosEmail: + def test_con_adjuntos_validos(self, temp_dir): + e = {} + for f in ["doc.pdf", "doc.xml", "imagen.jpg"]: + with open(os.path.join(temp_dir, f), "w") as fh: + fh.write("dummy") + + archivos = ["doc.pdf", "doc.xml", "imagen.jpg"] + resultado = procesar_adjuntos_email(e, archivos, temp_dir) + assert resultado == ["doc.pdf", "doc.xml"] + assert e["archivos"] == ["doc.pdf", "doc.xml"] + + def test_sin_adjuntos_validos(self, temp_dir): + e = {} + for f in ["imagen.jpg", "texto.txt"]: + with open(os.path.join(temp_dir, f), "w") as fh: + fh.write("dummy") + + archivos = ["imagen.jpg", "texto.txt"] + resultado = procesar_adjuntos_email(e, archivos, temp_dir) + assert resultado == [] + assert "archivos" not in e + + def test_archivos_vacios(self): + e = {} + resultado = procesar_adjuntos_email(e, [], "/tmp") + assert resultado == [] diff --git a/tests/test_file_manager.py b/tests/test_file_manager.py new file mode 100644 index 0000000..78142a1 --- /dev/null +++ b/tests/test_file_manager.py @@ -0,0 +1,128 @@ +import os +import sys +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from file_manager import renombrar_archivos_por_metadata, mover_validos_a_resultado, limpiar_carpeta_descarga + + +class TestRenombrarArchivosPorMetadata: + def test_renombrar_xml_valido(self, temp_dir): + ruta = os.path.join(temp_dir, "F001-100.xml") + with open(ruta, "w") as f: + f.write("") + + metadatos = [{ + "archivo": "F001-100.xml", + "extension": ".xml", + "tipo": "01", + "serie_numero": "F001-100", + "ruc": "20549758054", + "archivo_propuesto": "20549758054-01-F001-100" + }] + + renombrar_archivos_por_metadata(metadatos, temp_dir) + + assert not os.path.exists(ruta) + nuevo = os.path.join(temp_dir, "20549758054-01-F001-100.xml") + assert os.path.exists(nuevo) + assert metadatos[0]["archivo"] == "20549758054-01-F001-100.xml" + + def test_no_renombra_sin_metadata(self, temp_dir): + ruta = os.path.join(temp_dir, "documento.pdf") + with open(ruta, "w") as f: + f.write("dummy") + + metadatos = [{ + "archivo": "documento.pdf", + "extension": ".pdf", + "tipo": "99", + "serie_numero": "N/A", + "ruc": "N/A", + "archivo_propuesto": None + }] + + renombrar_archivos_por_metadata(metadatos, temp_dir) + + assert os.path.exists(ruta) + assert metadatos[0]["archivo"] == "documento.pdf" + + def test_renombrar_cdr(self, temp_dir): + ruta = os.path.join(temp_dir, "R-2025-123.xml") + with open(ruta, "w") as f: + f.write("") + + metadatos = [{ + "archivo": "R-2025-123.xml", + "extension": ".xml", + "tipo": "CDR", + "serie_numero": "R-2025-123", + "ruc": "20549758054", + "archivo_propuesto": "20549758054-CDR-R-2025-123" + }] + + renombrar_archivos_por_metadata(metadatos, temp_dir) + + assert not os.path.exists(ruta) + nuevo = os.path.join(temp_dir, "20549758054-CDR-R-2025-123.xml") + assert os.path.exists(nuevo) + + +class TestMoverValidosAResultado: + def test_mover_archivos_validos(self, temp_dir): + origen = os.path.join(temp_dir, "origen") + destino = os.path.join(temp_dir, "destino") + os.makedirs(origen) + + ruta_file = os.path.join(origen, "20549758054-01-F001-100.xml") + with open(ruta_file, "w") as f: + f.write("") + + metadatos = [{ + "archivo": "20549758054-01-F001-100.xml", + "extension": ".xml", + "tipo": "01", + "archivo_propuesto": "20549758054-01-F001-100" + }] + + mover_validos_a_resultado(metadatos, origen, destino) + + assert not os.path.exists(ruta_file) + assert os.path.exists(os.path.join(destino, "20549758054-01-F001-100.xml")) + + def test_no_mueve_sin_metadata(self, temp_dir): + origen = os.path.join(temp_dir, "origen") + destino = os.path.join(temp_dir, "destino") + os.makedirs(origen) + + ruta_file = os.path.join(origen, "basura.pdf") + with open(ruta_file, "w") as f: + f.write("dummy") + + metadatos = [{ + "archivo": "basura.pdf", + "extension": ".pdf", + "tipo": "99", + "archivo_propuesto": None + }] + + mover_validos_a_resultado(metadatos, origen, destino) + + assert os.path.exists(ruta_file) + assert os.path.exists(destino) + assert len(os.listdir(destino)) == 0 + + +class TestLimpiarCarpetaDescarga: + def test_limpia_archivos(self, temp_dir): + for f in ["a.pdf", "b.xml", "c.zip"]: + with open(os.path.join(temp_dir, f), "w") as fh: + fh.write("dummy") + + limpiar_carpeta_descarga(temp_dir) + + assert len(os.listdir(temp_dir)) == 0 + + def test_carpeta_no_existe(self): + limpiar_carpeta_descarga("/ruta/inexistente") diff --git a/tests/test_pdf_utilities.py b/tests/test_pdf_utilities.py new file mode 100644 index 0000000..f431c43 --- /dev/null +++ b/tests/test_pdf_utilities.py @@ -0,0 +1,99 @@ +import os +import sys +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from pdf_utilities import extraer_ruc, extraer_tipo_documento, extraer_serie_numero, obtener_datos_pdf + + +class TestExtraerRuc: + def test_ruc_formato_completo(self): + texto = "R.U.C.: 20549758054\nRazon Social: EMPRESA SAC" + assert extraer_ruc(texto) == "20549758054" + + def test_ruc_con_puntos(self): + texto = "R.U.C. 20549758054\nAlguna direccion" + assert extraer_ruc(texto) == "20549758054" + + def test_ruc_desnudo(self): + texto = "Factura Electronica\n20549758054\nMonto: 100.00" + assert extraer_ruc(texto) == "20549758054" + + def test_ruc_con_espacio(self): + texto = "RUC: 20605626921\nBoleta de Venta" + assert extraer_ruc(texto) == "20605626921" + + def test_sin_ruc(self): + texto = "Documento sin numero de identificacion" + assert extraer_ruc(texto) == "N/A" + + +class TestExtraerTipoDocumento: + def test_factura_electronica(self): + texto = "FACTURA ELECTRONICA\nRUC: 20549758054" + assert extraer_tipo_documento(texto) == "01" + + def test_factura_simple(self): + texto = "FACTURA DE VENTA\nRUC: 20549758054" + assert extraer_tipo_documento(texto) == "01" + + def test_boleta(self): + texto = "BOLETA DE VENTA ELECTRONICA\nRUC: 20549758054" + assert extraer_tipo_documento(texto) == "03" + + def test_nota_credito(self): + texto = "NOTA DE CREDITO ELECTRONICA\nRUC: 20549758054" + assert extraer_tipo_documento(texto) == "07" + + def test_nota_debito(self): + texto = "NOTA DE DEBITO\nRUC: 20549758054" + assert extraer_tipo_documento(texto) == "08" + + def test_recibo_honorarios(self): + texto = "RECIBO POR HONORARIOS\nRUC: 10456789012" + assert extraer_tipo_documento(texto) == "14" + + def test_guia_remision(self): + texto = "GUIA DE REMISION ELECTRONICA\nRUC: 20549758054" + assert extraer_tipo_documento(texto) == "09" + + def test_tipo_desconocido(self): + texto = "DOCUMENTO INVALIDO\nRUC: 20549758054" + assert extraer_tipo_documento(texto) == "99" + + +class TestExtraerSerieNumero: + def test_serie_factura_guion(self): + texto = "FACTURA ELECTRONICA\nF001-00005103\nRUC: 20549758054" + assert extraer_serie_numero(texto) == "F001-00005103" + + def test_serie_boleta_guion(self): + texto = "BOLETA ELECTRONICA\nB001-123456\nRUC: 20549758054" + assert extraer_serie_numero(texto) == "B001-123456" + + def test_serie_eg_con_guion(self): + texto = "GUIA DE REMISION\nEG07-2367\nRUC: 20549758054" + assert extraer_serie_numero(texto) == "EG07-2367" + + def test_sin_serie(self): + texto = "Documento sin serie" + assert extraer_serie_numero(texto) == "No encontrado" + + +PDF_REAL = os.environ.get("TEST_PDF_PATH") + + +@pytest.mark.skipif(not PDF_REAL or not os.path.exists(PDF_REAL), + reason="Define TEST_PDF_PATH con la ruta a un PDF real. Ej: TEST_PDF_PATH=factura.pdf pytest ...") +def test_pdf_real_con_metadata(): + """Extrae metadata de un PDF real. Usar: TEST_PDF_PATH=/ruta/pdf python3 -m pytest tests/ -s""" + datos = obtener_datos_pdf(PDF_REAL) + assert datos["valido"] is True, f"Error: {datos.get('error')}" + print(f"\n{'='*50}") + print(f"Archivo: {PDF_REAL}") + print(f"{'='*50}") + print(f" RUC: {datos['ruc']}") + print(f" Tipo doc: {datos['tipo_doc']}") + print(f" Serie-Numero: {datos['id']}") + print(f"{'='*50}")