10 Commits
Author SHA1 Message Date
georgeandClaude Sonnet 5 ee64e5f773 se usa fecha_vencimiento en DueDate de las cuotas del draft
Las cuotas (DocumentInstallments) ahora usan la misma fecha_vencimiento
calculada para DocDueDate, en vez de fecha_emision del XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 09:32:11 -05:00
georgeandClaude Sonnet 5 c532170ac2 se agrega fallback OWTR y calculo de fecha de vencimiento por condicion de pago
- hana_manager.get_delivery ahora busca en ODLN y, si no encuentra, en OWTR
- sapb1_manager resuelve StockTransfers -> Orders (via U_IDC_NUMOV) para
  validar precios de flete cuando la guia es un traslado de inventario
- se corrige el orden de parametros tipo/serie/numero (sin cruces) en
  get_delivery, get_delivery_notes y _get_delivery_entries
- se agrega sapb1_manager.obtener_condicion_pago (BusinessPartners +
  PaymentTermsTypes) y pipeline._calcular_fecha_vencimiento para que
  DocDueDate del draft sea fecha de contabilizacion + dias de la condicion
  de pago del proveedor
- se agrega variante de regex para "VALOR REFER" truncado en sunat_schema.py
- se actualiza documentacion (docs/) reflejando estos cambios

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 13:21:04 -05:00
GeorgeandClaude Sonnet 5 d023f14ad9 se renombra carpeta test a tools
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 07:41:39 -05:00
GeorgeandClaude Sonnet 5 911bd2da34 se agrega integración con SAP HANA, manejo de errores en el demonio y actualización de documentación
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 07:40:26 -05:00
George 3c2bb2b714 se agrega manejo de ejecución por horarios 2026-07-13 14:20:57 -05:00
George 0f17284020 se agregó comentarios en el proceso de pipeline.py 2026-07-13 12:24:59 -05:00
George 603243b7e0 ahora se procesa de inicio a fin correo por correo 2026-07-09 09:48:30 -05:00
George 8cdd075d15 se agrego el modulo sunat_schema.py que lee el archivo xml y extrae la información 2026-06-03 18:32:18 -05:00
George a29a44e361 se termina la descarga de los adjuntos y solo se deja los mails con adjuntos pdf y xml 2026-06-01 18:05:44 -05:00
george c08554e775 primer commit 2026-06-01 16:31:24 -05:00
289 changed files with 1376 additions and 10234 deletions

No files matched your search

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