Compare commits
10
Commits
d5a82ea025
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee64e5f773 | ||
|
|
c532170ac2 | ||
|
|
d023f14ad9 | ||
|
|
911bd2da34 | ||
|
|
3c2bb2b714 | ||
|
|
0f17284020 | ||
|
|
603243b7e0 | ||
|
|
8cdd075d15 | ||
|
|
a29a44e361 | ||
|
|
c08554e775 |
No files matched your search
@@ -10,7 +10,4 @@ adjuntos/
|
||||
resultado/
|
||||
emails.db
|
||||
.env.dev
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
build/
|
||||
procesos/
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
Uso (desde cualquier directorio):
|
||||
python tools/consulta_rapida.py --set <nombre_del_set>
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
@@ -13,13 +10,8 @@ import requests
|
||||
from datetime import datetime, timedelta
|
||||
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 logger_manager
|
||||
from config_validator import verificar_configuracion
|
||||
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:
|
||||
# Ruta absoluta: no depende del directorio de trabajo desde el que se lance el proceso
|
||||
load_dotenv(dotenv_path=os.path.join(RAIZ, ".env"))
|
||||
verificar_configuracion()
|
||||
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), ".env"))
|
||||
email_addr = os.getenv("EMAIL", "")
|
||||
download_folder = os.getenv("DOWNLOAD_FOLDER", "")
|
||||
output_folder = os.getenv("OUTPUT_FOLDER", "")
|
||||
download_folder = os.getenv("DOWNLOAD_FOLDER", "adjuntos")
|
||||
output_folder = os.getenv("OUTPUT_FOLDER", "resultado")
|
||||
|
||||
if not email_addr:
|
||||
print("❌ EMAIL no configurado en .env")
|
||||
return
|
||||
|
||||
database_utilities.init_db()
|
||||
token = step_authenticate()
|
||||
@@ -77,15 +72,15 @@ def run_pipeline_for_ids(ids: list[str]) -> None:
|
||||
# ============================================================================
|
||||
if __name__ == "__main__":
|
||||
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(
|
||||
"--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()
|
||||
|
||||
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):
|
||||
print(f"❌ No se encuentra {json_path}")
|
||||
sys.exit(1)
|
||||
+1
-34
@@ -9,9 +9,7 @@ DB_NAME = "emails.db"
|
||||
# ============================================================================
|
||||
def init_db() -> None:
|
||||
"""
|
||||
Inicializa la base de datos SQLite y crea las tablas si no existen:
|
||||
``emails`` (correos ya procesados) y ``comprobantes`` (códigos de
|
||||
seguimiento y resultado de cada comprobante, ver ``seguimiento.py``).
|
||||
Inicializa la base de datos SQLite y crea la tabla de correos si no existe.
|
||||
"""
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
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()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
```
|
||||
@@ -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
@@ -4,28 +4,17 @@
|
||||
|
||||
| Archivo | Líneas | Rol |
|
||||
|---------|--------|-----|
|
||||
| `main.py` | 137 | Demonio orquestador (loop de horarios) |
|
||||
| `pipeline.py` | 272 | Orquestador del pipeline (autenticar, descargar correos, procesar un correo) |
|
||||
| `pipeline_utils.py` | 27 | `log()` (logging con indentación de árbol y `severity`) y `ruta_carpeta()` |
|
||||
| `adjuntos.py` | 82 | Descarga de adjuntos (Graph) y filtrado PDF/XML |
|
||||
| `documentos.py` | 372 | Extracción PDF/XML, asignación de guías, copia a red |
|
||||
| `persistencia.py` | 163 | Guardado de JSON de resultados y de drafts |
|
||||
| `draft_sap.py` | 414 | Validación de precios contra SAP, valor referencial y construcción del draft |
|
||||
| `notificaciones.py` | 117 | Cuerpos HTML de los correos de notificación |
|
||||
| `sunat_schema.py` | 722 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
|
||||
| `pdf_utilities.py` | 471 | Parseo de PDFs (factura, guías) |
|
||||
| `sapb1_manager.py` | 401 | Comunicación con SAP B1 Service Layer |
|
||||
| `outlook_utilities.py` | 249 | Comunicación con Microsoft Graph |
|
||||
| `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).
|
||||
| `main.py` | 134 | Demonio orquestador (loop de horarios) |
|
||||
| `pipeline.py` | 1284 | Pipeline core (procesar un correo completo) |
|
||||
| `sunat_schema.py` | 720 | Parseo de XML SUNAT (Invoice, DespatchAdvice) |
|
||||
| `pdf_utilities.py` | 464 | Parseo de PDFs (factura, guías) |
|
||||
| `sapb1_manager.py` | 321 | Comunicación con SAP B1 Service Layer |
|
||||
| `outlook_utilities.py` | 234 | Comunicación con Microsoft Graph |
|
||||
| `hana_manager.py` | 123 | Comunicación con SAP HANA directa (fallback de `sapb1_manager` y valor referencial) |
|
||||
| `database_utilities.py` | 116 | SQLite local (cache de correos) |
|
||||
| `consulta_rapida.py` | 113 | Script auxiliar para reprocesar correos por ID |
|
||||
| `email_utilities.py` | 52 | Utilitarios de adjuntos (ZIP, filtrado) |
|
||||
| `logger_manager.py` | 149 | Logging a archivo rotado (30 días) + consola + captura de `print()` |
|
||||
|
||||
---
|
||||
|
||||
@@ -43,19 +32,8 @@ main.py
|
||||
├── outlook_utilities.py
|
||||
├── email_utilities.py
|
||||
└── database_utilities.py
|
||||
```
|
||||
|
||||
Módulos que `pipeline.py` orquesta (todos usan `pipeline_utils.py`):
|
||||
|
||||
```
|
||||
pipeline.py
|
||||
├── adjuntos.py → outlook_utilities
|
||||
├── documentos.py → sunat_schema, pdf_utilities
|
||||
├── persistencia.py → sunat_schema, pdf_utilities
|
||||
├── draft_sap.py → sapb1_manager, hana_manager
|
||||
└── notificaciones.py
|
||||
|
||||
tools/consulta_rapida.py
|
||||
consulta_rapida.py
|
||||
├── logger_manager.py
|
||||
├── database_utilities.py
|
||||
└── pipeline.py (_procesar_un_correo, step_authenticate)
|
||||
@@ -72,28 +50,28 @@ main()
|
||||
├── step_fetch_emails() → emails[]
|
||||
└── _procesar_un_correo() (por cada email)
|
||||
├── BD: verificar duplicado
|
||||
├── adjuntos.descargar_adjuntos() → archivos
|
||||
├── email_utilities.descomprimir_adjuntos() ZIPs
|
||||
├── adjuntos.filtrar_y_limpiar() → solo PDF/XML
|
||||
├── documentos.procesar_pdfs() → pdf_cache
|
||||
├── _descargar_adjuntos() → archivos
|
||||
├── _descomprimir() ZIPs
|
||||
├── _filtrar_y_limpiar() → solo PDF/XML
|
||||
├── _procesar_pdfs() → pdf_cache
|
||||
│ └── pdf_utilities.extraer_datos_pdf()
|
||||
├── documentos.procesar_xmls(pdf_cache) → documentos[]
|
||||
├── _procesar_xmls(pdf_cache) → documentos[]
|
||||
│ ├── sunat_schema.extraer_datos_xml()
|
||||
│ └── documentos.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
|
||||
├── documentos.validar_guias(documentos) → advierte guías con datos incompletos
|
||||
├── persistencia.guardar_resultado(documentos)
|
||||
├── draft_sap.validate_prices(factura) → (ok, resultados)
|
||||
│ └── _copiar_a_red() → copia PDF/XML a ruta de red (RUTA_RED_FACTURACION)
|
||||
├── _completar_guias(documentos, pdf_cache) → completa guías faltantes desde PDF
|
||||
├── _validar_guias(documentos) → advierte guías con datos incompletos
|
||||
├── _guardar_resultado(documentos)
|
||||
├── _validate_prices(factura) → (ok, resultados)
|
||||
│ └── sapb1_manager.get_delivery_notes()
|
||||
│ ├── SL (Service Layer) GET /DeliveryNotes
|
||||
│ ├── HANA fallback (get_delivery: ODLN, luego OWTR)
|
||||
│ └── si vino de OWTR: GET StockTransfers(DocEntry)
|
||||
│ → 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)
|
||||
├── 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
|
||||
├── persistencia.guardar_draft_json(draft)
|
||||
├── _guardar_draft_json(draft)
|
||||
├── sapb1_manager.create_draft(draft)
|
||||
└── 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 |
|
||||
|--------|---------|-----------|
|
||||
| `pipeline_utils` | `ruta_carpeta` | Resuelve una ruta relativa al directorio del proyecto |
|
||||
| `pipeline_utils` | `log` | Imprime con indentación de árbol (niveles 1-4) |
|
||||
| `adjuntos` | `descargar_adjuntos` | Descarga adjuntos vía Graph API |
|
||||
| `adjuntos` | `filtrar_y_limpiar` | Filtra PDF/XML, elimina el resto |
|
||||
| `documentos` | `copiar_a_red` | Copia PDF/XML a la ruta de red de Facturación Electrónica (`RUTA_RED_FACTURACION`) |
|
||||
| `documentos` | `procesar_pdfs` | Extrae datos de PDFs, renombra, genera cache |
|
||||
| `documentos` | `procesar_xmls` | 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 |
|
||||
| `documentos` | `validar_guias` | Advierte (log) guías con datos incompletos |
|
||||
| `persistencia` | `guardar_resultado` | Guarda JSON con datos extraídos (XML y PDF) |
|
||||
| `persistencia` | `guardar_draft_json` | Guarda draft JSON para depuración |
|
||||
| `draft_sap` | `calcular_fecha_vencimiento` | Calcula `DocDueDate` = fecha de contabilización + días de la condición de pago (`PaymentTermsTypes`) |
|
||||
| `draft_sap` | `build_sap_draft` | Construye payload del draft SAP B1 |
|
||||
| `draft_sap` | `validate_prices` | Valida precios contra DeliveryNotes / Orders (SAP) |
|
||||
| `draft_sap` | `obtener_valor_referencial_sap` | Consulta `Sp_ObtenerValorReferencial` en HANA por guía |
|
||||
| `notificaciones` | `build_validation_html` | Genera tabla HTML de validación de precios |
|
||||
| `notificaciones` | `build_valor_referencial_html` | Genera tabla HTML comparando valor referencial XML vs SAP |
|
||||
| `notificaciones` | `build_notification_body` | Arma cuerpo del email de notificación |
|
||||
| Función | Líneas | Propósito |
|
||||
|---------|--------|-----------|
|
||||
| `_ruta_carpeta` (módulo) | 33-34 | Resuelve una ruta relativa al directorio del script |
|
||||
| `_copiar_a_red` (módulo) | 37-94 | Copia PDF/XML a la ruta de red de Facturación Electrónica (`RUTA_RED_FACTURACION`) |
|
||||
| `_descargar_adjuntos` | 169-196 | Descarga adjuntos vía Graph API |
|
||||
| `_descomprimir` | 198-211 | Descomprime ZIPs |
|
||||
| `_filtrar_y_limpiar` | 213-253 | Filtra PDF/XML, elimina el resto |
|
||||
| `_procesar_pdfs` | 255-308 | Extrae datos de PDFs, renombra, genera cache |
|
||||
| `_procesar_xmls` | 310-463 | Procesa XMLs, cruza con PDF, asigna guías, copia a red |
|
||||
| `_completar_guias` | 465-522 | Completa guías faltantes en líneas XML desde el PDF cache |
|
||||
| `_validar_guias` | 524-532 | Advierte (log) guías con datos incompletos |
|
||||
| `_guardar_resultado` | 534-653 | Guarda JSON con datos extraídos (XML y PDF) |
|
||||
| `_calcular_fecha_vencimiento` | 665-692 | Calcula `DocDueDate` = fecha de contabilización + días de la condición de pago (`PaymentTermsTypes`) |
|
||||
| `_build_sap_draft` | 694-855 | Construye payload del draft SAP B1 |
|
||||
| `_validate_prices` | 856-1030 | Valida precios contra DeliveryNotes / Orders (SAP) |
|
||||
| `_build_validation_html` | 1032-1061 | Genera tabla HTML de validación de precios |
|
||||
| `_obtener_valor_referencial_sap` | 1062-1090 | Consulta `Sp_ObtenerValorReferencial` en HANA por guía |
|
||||
| `_build_valor_referencial_html` | 1091-1126 | Genera tabla HTML comparando valor referencial XML vs SAP |
|
||||
| `_build_notification_body` | 1127-1168 | Arma cuerpo del email de notificación |
|
||||
| `_guardar_draft_json` | 1169-1195 | Guarda draft JSON para depuración |
|
||||
+2
-7
@@ -96,12 +96,7 @@ if __name__ == "__main__":
|
||||
1. logger_manager.setup_logging()
|
||||
└── Configura logging a archivo rotado (logs/descarga_factura_transporte.log,
|
||||
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;
|
||||
los print() restantes se registran como INFO).
|
||||
2. logger.info(...) banner de inicio, incluyendo logger_manager.LOG_FILE
|
||||
al mismo log (no hace falta migrar cada print a logger.info).
|
||||
2. print(...) banner de inicio, incluyendo logger_manager.LOG_FILE
|
||||
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.
|
||||
```
|
||||
@@ -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
@@ -4,18 +4,12 @@
|
||||
|
||||
```
|
||||
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 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
|
||||
|
||||
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?]
|
||||
B -- Sí --> C[⏭️ Saltar]
|
||||
B -- No --> D[Guardar en BD]
|
||||
D --> E[3. descargar_adjuntos]
|
||||
D --> E[3. _descargar_adjuntos]
|
||||
E --> F{Hay adjuntos?}
|
||||
F -- No --> C
|
||||
F -- Sí --> G[4. _descomprimir ZIPs]
|
||||
G --> H[5. filtrar_y_limpiar]
|
||||
G --> H[5. _filtrar_y_limpiar]
|
||||
H --> I{Hay PDF/XML?}
|
||||
I -- No --> C
|
||||
I -- Sí --> J[6. procesar_pdfs]:::highlight
|
||||
J --> K[7. procesar_xmls]:::highlight
|
||||
K --> K2[8. completar_guias]
|
||||
K2 --> K3[9. validar_guias]
|
||||
I -- Sí --> J[6. _procesar_pdfs]:::highlight
|
||||
J --> K[7. _procesar_xmls]:::highlight
|
||||
K --> K2[8. _completar_guias]
|
||||
K2 --> K3[9. _validar_guias]
|
||||
K3 --> L{Hay documentos?}
|
||||
L -- No --> C
|
||||
L -- Sí --> M[10. guardar_resultado]
|
||||
L -- Sí --> M[10. _guardar_resultado]
|
||||
M --> N[11. Filtrar facturas tipo 01]
|
||||
N --> O{Hay facturas?}
|
||||
O -- No --> C
|
||||
O -- Sí --> P[Bucle: por cada factura]
|
||||
|
||||
P --> Q[12. validate_prices]:::highlight
|
||||
Q --> Q2[12.5 obtener_valor_referencial_sap]
|
||||
Q2 --> R[13. build_sap_draft]:::highlight
|
||||
R --> S[14. guardar_draft_json]
|
||||
P --> Q[12. _validate_prices]:::highlight
|
||||
Q --> Q2[12.5 _obtener_valor_referencial_sap]
|
||||
Q2 --> R[13. _build_sap_draft]:::highlight
|
||||
R --> S[14. _guardar_draft_json]
|
||||
S --> T{Todas coinciden?}
|
||||
T -- Sí --> U[15. sapb1_manager.create_draft]
|
||||
T -- No --> V[⏭️ Draft NO creado]
|
||||
@@ -81,38 +75,38 @@ flowchart TD
|
||||
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 |
|
||||
|---------|--------|-----------|
|
||||
| `ruta_carpeta()` | `pipeline_utils` | Resuelve una ruta relativa al directorio del proyecto |
|
||||
| `log(msg, level, last)` | `pipeline_utils` | Imprime con indentación de árbol |
|
||||
| `copiar_a_red(data)` | `documentos` | Copia PDF/XML de un comprobante a la ruta de red configurable |
|
||||
| `descargar_adjuntos(email, email_addr, token, download_folder)` | `adjuntos` | Descarga adjuntos vía Microsoft Graph |
|
||||
| `filtrar_y_limpiar(email, download_folder)` | `adjuntos` | Filtra solo PDF/XML, elimina el resto |
|
||||
| **`procesar_pdfs(email, download_folder)`** | `documentos` | **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** |
|
||||
| `completar_guias(documentos, pdf_cache)` | `documentos` | 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) |
|
||||
| `guardar_resultado(documentos, datos_pdf, output_folder)` | `persistencia` | 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)` | `draft_sap` | Calcula `DocDueDate` = fecha de contabilización + días de `PaymentTermsTypes` |
|
||||
| **`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)`** | `draft_sap` | **Valida precios contra DeliveryNotes/Orders SAP** |
|
||||
| `obtener_valor_referencial_sap(factura)` | `draft_sap` | 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)` | `notificaciones` | Genera tabla HTML comparando valor referencial XML vs SAP |
|
||||
| `build_notification_body(status, factura, validation_table, valor_referencial_table, **kwargs)` | `notificaciones` | Arma cuerpo del email de notificación |
|
||||
| Función | Línea | Propósito |
|
||||
|---------|-------|-----------|
|
||||
| `_ruta_carpeta()` (módulo) | 33 | Resuelve una ruta relativa al directorio del script |
|
||||
| `_copiar_a_red(data, _log)` (módulo) | 37 | Copia PDF/XML de un comprobante a la ruta de red configurable |
|
||||
| `_descargar_adjuntos()` | 169 | Descarga adjuntos vía Microsoft Graph |
|
||||
| `_descomprimir()` | 198 | Descomprime archivos ZIP |
|
||||
| `_filtrar_y_limpiar()` | 213 | Filtra solo PDF/XML, elimina el resto |
|
||||
| **`_procesar_pdfs()`** | **255** | **Extrae datos de PDFs, renombra, genera cache** |
|
||||
| **`_procesar_xmls(pdf_cache)`** | **310** | **Procesa XMLs, cruza con PDF, asigna guías, copia a red** |
|
||||
| `_completar_guias(documentos, pdf_cache)` | 465 | Completa guías faltantes en líneas XML desde el PDF cache |
|
||||
| `_validar_guias(documentos)` | 524 | Advierte (log) guías con datos incompletos (falta tipo/serie/numero) |
|
||||
| `_guardar_resultado(documentos, datos_pdf)` | 534 | Guarda JSON con datos extraídos (XML y PDF) |
|
||||
| `_calcular_fecha_vencimiento(fecha_base, condicion_pago)` | 665 | 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)** |
|
||||
| **`_validate_prices(factura)`** | **856** | **Valida precios contra DeliveryNotes/Orders SAP** |
|
||||
| `_build_validation_html(resultados)` | 1032 | Genera tabla HTML de validación de precios |
|
||||
| `_obtener_valor_referencial_sap(factura)` | 1062 | Consulta `Sp_ObtenerValorReferencial` en HANA por cada guía única |
|
||||
| `_build_valor_referencial_html(factura, guias_valor_ref)` | 1091 | Genera tabla HTML comparando valor referencial XML vs SAP |
|
||||
| `_build_notification_body(status, factura, validation_table, valor_referencial_table, **kwargs)` | 1127 | 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.
|
||||
|
||||
@@ -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` 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.
|
||||
- Se invoca desde `procesar_xmls`, justo después de resolver `data["ruta_pdf"]`.
|
||||
- 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"]`.
|
||||
|
||||
---
|
||||
|
||||
@@ -140,23 +134,23 @@ run_pipeline
|
||||
│ └── outlook_utilities.get_emails
|
||||
└── _procesar_un_correo (por cada email)
|
||||
├── database_utilities.get_existing_ids / save_emails
|
||||
├── descargar_adjuntos
|
||||
├── _descargar_adjuntos
|
||||
│ └── outlook_utilities.download_attachments
|
||||
├── _descomprimir
|
||||
│ └── email_utilities.descomprimir_adjuntos
|
||||
├── filtrar_y_limpiar
|
||||
├── procesar_pdfs
|
||||
├── _filtrar_y_limpiar
|
||||
├── _procesar_pdfs
|
||||
│ └── pdf_utilities.extraer_datos_pdf / renombrar_pdf
|
||||
├── procesar_xmls
|
||||
├── _procesar_xmls
|
||||
│ ├── sunat_schema.extraer_datos_xml
|
||||
│ ├── pdf_utilities.extraer_referencias
|
||||
│ └── copiar_a_red
|
||||
├── completar_guias
|
||||
├── validar_guias
|
||||
├── guardar_resultado
|
||||
│ └── _copiar_a_red
|
||||
├── _completar_guias
|
||||
├── _validar_guias
|
||||
├── _guardar_resultado
|
||||
│ └── sunat_schema.split_serie_numero, pdf_utilities.TIPO_MAP
|
||||
├── (por cada factura tipo 01)
|
||||
│ ├── validate_prices
|
||||
│ ├── _validate_prices
|
||||
│ │ ├── sapb1_manager.get_zonas
|
||||
│ │ └── sapb1_manager.get_delivery_notes
|
||||
│ │ ├── hana_manager.get_delivery (fallback si SL no responde;
|
||||
@@ -164,15 +158,15 @@ run_pipeline
|
||||
│ │ └── si el DocEntry vino de OWTR (StockTransfers), resuelve
|
||||
│ │ además la Orden de Venta asociada (Orders vía
|
||||
│ │ U_IDC_NUMOV) para poder validar las líneas FL*
|
||||
│ ├── build_validation_html
|
||||
│ ├── obtener_valor_referencial_sap
|
||||
│ ├── _build_validation_html
|
||||
│ ├── _obtener_valor_referencial_sap
|
||||
│ │ └── hana_manager.obtener_valor_referencial
|
||||
│ ├── build_valor_referencial_html
|
||||
│ ├── build_sap_draft
|
||||
│ ├── _build_valor_referencial_html
|
||||
│ ├── _build_sap_draft
|
||||
│ │ └── sapb1_manager.obtener_condicion_pago (BusinessPartners +
|
||||
│ │ PaymentTermsTypes, para calcular DocDueDate)
|
||||
│ ├── guardar_draft_json
|
||||
│ ├── _guardar_draft_json
|
||||
│ ├── sapb1_manager.create_draft (si todas las líneas coinciden)
|
||||
│ ├── build_notification_body
|
||||
│ ├── _build_notification_body
|
||||
│ └── outlook_utilities.send_email
|
||||
```
|
||||
@@ -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"]
|
||||
|
||||
R --> S["Comparar total_linea (XML)<br>vs sap_total (FL*)"]
|
||||
S --> T{"abs(diff) < 0.01<br>y ninguna guía falló?"}
|
||||
T -->|Sí| U["ok = True<br>draft_quantity = int(Quantity máx FL*)<br>draft_price = suma PriceAfterVAT FL*"]
|
||||
T -->|No| V["ok = False<br>draft_quantity = int(cantidad XML)<br>draft_price = total_linea / cantidad"]
|
||||
S --> T{"abs(diff) < 0.01?"}
|
||||
T -->|Sí| U["ok = True<br>use_sap_prices si qty==1<br>y total_sap_qty≠1"]
|
||||
T -->|No| V["ok = False"]
|
||||
|
||||
U --> W["Resultado por línea:<br>ok, draft_price, sap_price,<br>label, warehouse_code, guias_lt[]"]
|
||||
U --> W["Resultado por línea:<br>ok, draft_price, sap_price,<br>draft_cantidad, guias_lt[]"]
|
||||
V --> W
|
||||
W --> C
|
||||
C --> X["todas_coinciden = all(ok)"]
|
||||
```
|
||||
|
||||
## 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
@@ -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
@@ -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
@@ -1,9 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
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
|
||||
ruta_zip = os.path.join(ruta, adjunto)
|
||||
if not os.path.exists(ruta_zip):
|
||||
logger.warning(f"⚠️ No existe: {ruta_zip}")
|
||||
print(f"⚠️ No existe: {ruta_zip}")
|
||||
continue
|
||||
try:
|
||||
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)
|
||||
nuevos_adjuntos.extend(nombres_extraidos)
|
||||
logger.info(
|
||||
print(
|
||||
f"📦 {adjunto}: {len(nombres_extraidos)} archivos extraídos"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Error descomprimiendo {adjunto}: {e}")
|
||||
print(f"⚠️ Error descomprimiendo {adjunto}: {e}")
|
||||
email["adjuntos"].extend(nuevos_adjuntos)
|
||||
return emails
|
||||
except Exception as ex:
|
||||
logger.error(f"❌ Error general: {ex}")
|
||||
print(f"❌ Error general: {ex}")
|
||||
return emails
|
||||
|
||||
# ============================================================================
|
||||
|
||||
+5
-6
@@ -1,12 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
from datetime import datetime, date
|
||||
from hdbcli import dbapi
|
||||
from dotenv import load_dotenv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURACIÓN
|
||||
# ============================================================================
|
||||
@@ -34,7 +33,7 @@ def get_connection():
|
||||
)
|
||||
return conn
|
||||
except Exception as e:
|
||||
logger.error(f" ❌ Error conectando a HANA: {e}")
|
||||
print(f" ❌ Error conectando a HANA: {e}")
|
||||
return None
|
||||
|
||||
# ============================================================================
|
||||
@@ -92,7 +91,7 @@ def _query_delivery_table(connection, tabla: str, tipo: str, serie: str, numero:
|
||||
results.append(registro)
|
||||
return results
|
||||
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 []
|
||||
finally:
|
||||
if cursor:
|
||||
@@ -130,7 +129,7 @@ def obtener_valor_referencial(serie: str, numero: str) -> float:
|
||||
valor = dict(zip(columns, row)).get("ValorReferencial")
|
||||
return float(valor) if valor is not None else 0.0
|
||||
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
|
||||
finally:
|
||||
if cursor:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import argparse
|
||||
@@ -6,11 +5,8 @@ from datetime import datetime, timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import logger_manager
|
||||
from config_validator import verificar_configuracion
|
||||
from pipeline import run_pipeline
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
|
||||
HORARIOS = [
|
||||
@@ -71,14 +67,13 @@ def main() -> None:
|
||||
|
||||
# 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"))
|
||||
verificar_configuracion()
|
||||
|
||||
EMAIL = os.getenv("EMAIL", "")
|
||||
DOWNLOAD_FOLDER = os.getenv("DOWNLOAD_FOLDER", "")
|
||||
OUTPUT_FOLDER = os.getenv("OUTPUT_FOLDER", "")
|
||||
|
||||
logger.info("🚀 Demonio iniciado")
|
||||
logger.info("=" * 60)
|
||||
print("🚀 Demonio iniciado")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -91,8 +86,8 @@ def main() -> None:
|
||||
|
||||
intervalo, modo = resultado
|
||||
|
||||
logger.info(
|
||||
f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
||||
print(
|
||||
f"\n[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] "
|
||||
f"Iniciando ciclo [{modo}] (cada {intervalo}s)"
|
||||
)
|
||||
|
||||
@@ -101,14 +96,14 @@ def main() -> None:
|
||||
duracion = (datetime.now() - inicio_ciclo).total_seconds()
|
||||
|
||||
if duracion > intervalo:
|
||||
logger.warning(
|
||||
print(
|
||||
f"⚠️ El ciclo demoró {duracion:.1f}s "
|
||||
f"(intervalo configurado {intervalo}s)"
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
@@ -116,22 +111,25 @@ def main() -> None:
|
||||
|
||||
despertar = inicio_ciclo + timedelta(seconds=dormir)
|
||||
|
||||
logger.info(f"[{inicio_ciclo:%Y-%m-%d %H:%M:%S}] Fuera del horario.")
|
||||
logger.info(f"Próxima ejecución: {despertar:%Y-%m-%d %H:%M:%S}")
|
||||
print(
|
||||
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)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("🛑 Demonio detenido por el usuario.")
|
||||
print("\n\n🛑 Demonio detenido por el usuario.")
|
||||
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger_manager.setup_logging()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S}")
|
||||
logger.info(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
|
||||
logger.info("=" * 60)
|
||||
print("=" * 60)
|
||||
print(f"🚀 Demonio iniciado: {datetime.now():%Y-%m-%d %H:%M:%S}")
|
||||
print(f"📄 Log guardado en: {logger_manager.LOG_FILE}")
|
||||
print("=" * 60)
|
||||
|
||||
main()
|
||||
@@ -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}"
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
import msal
|
||||
import requests
|
||||
import warnings
|
||||
@@ -6,8 +5,6 @@ import base64
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
# ============================================================================
|
||||
@@ -29,7 +26,7 @@ VERIFY_SSL = os.getenv("VERIFY_SSL", "False").lower() == "true"
|
||||
def get_access_token() -> str:
|
||||
"""
|
||||
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}"
|
||||
|
||||
@@ -217,7 +214,7 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
||||
"contentBytes": encoded_content
|
||||
})
|
||||
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
|
||||
email_data = {
|
||||
@@ -237,13 +234,13 @@ def send_email(access_token: str, from_email: str, to_recipients: list[str], sub
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=email_data, verify=verify_ssl)
|
||||
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
|
||||
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
|
||||
except Exception as e:
|
||||
logger.error(f" ├── ERROR: Excepcion al enviar correo: {e}")
|
||||
print(f" ├── ERROR: Excepcion al enviar correo: {e}")
|
||||
return False
|
||||
|
||||
# ============================================================================
|
||||
-163
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -1,6 +1,6 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=64"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
build-backend = "setuptools.backends._legacy:_Backend"
|
||||
|
||||
[project]
|
||||
name = "descarga_factura_transporte"
|
||||
@@ -8,40 +8,20 @@ version = "1.1.0"
|
||||
description = "Descarga y procesamiento de facturas de transporte desde Office365"
|
||||
requires-python = ">=3.11"
|
||||
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",
|
||||
"pdfminer.six==20260107",
|
||||
"pdfplumber==0.11.10",
|
||||
"pillow==12.3.0",
|
||||
"pycparser==3.0",
|
||||
"PyJWT==2.13.0",
|
||||
"pypdfium2==5.11.0",
|
||||
"python-dotenv==1.2.2",
|
||||
"requests==2.34.2",
|
||||
"urllib3==2.7.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==9.1.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = [
|
||||
"adjuntos",
|
||||
"config_validator",
|
||||
"database_utilities",
|
||||
"documentos",
|
||||
"draft_sap",
|
||||
"email_utilities",
|
||||
"hana_manager",
|
||||
"logger_manager",
|
||||
"main",
|
||||
"notificaciones",
|
||||
"outlook_utilities",
|
||||
"pdf_utilities",
|
||||
"persistencia",
|
||||
"pipeline",
|
||||
"pipeline_utils",
|
||||
"sapb1_manager",
|
||||
"sunat_schema",
|
||||
]
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
hdbcli==2.29.25
|
||||
msal==1.37.0
|
||||
pdfplumber==0.11.10
|
||||
python-dotenv==1.2.2
|
||||
requests==2.34.2
|
||||
urllib3==2.7.0
|
||||
hdbcli
|
||||
msal
|
||||
pdfplumber
|
||||
python-dotenv
|
||||
requests
|
||||
urllib3
|
||||
+26
-29
@@ -1,5 +1,4 @@
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import urllib3
|
||||
import os
|
||||
@@ -8,8 +7,6 @@ from typing import Optional, Dict, Any
|
||||
from dotenv import load_dotenv
|
||||
import hana_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CONFIGURACIÓN
|
||||
@@ -40,7 +37,7 @@ def login() -> dict:
|
||||
"UserName": SAPB1_USER,
|
||||
"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(
|
||||
f"{SAPB1_URL}/Login",
|
||||
json=payload,
|
||||
@@ -58,7 +55,7 @@ def login() -> dict:
|
||||
|
||||
headers = {"Cookie": f"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
|
||||
|
||||
|
||||
@@ -67,9 +64,9 @@ def login() -> dict:
|
||||
# ============================================================================
|
||||
def logout() -> None:
|
||||
if not _sap_session["b1session"]:
|
||||
logger.warning("⚠️ No hay sesión activa para cerrar.")
|
||||
print("⚠️ No hay sesión activa para cerrar.")
|
||||
return
|
||||
logger.info("🔓 Cerrando sesión SAP B1...")
|
||||
print("🔓 Cerrando sesión SAP B1...")
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{SAPB1_URL}/Logout",
|
||||
@@ -78,11 +75,11 @@ def logout() -> None:
|
||||
verify=SAPB1_VERIFY_SSL,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
logger.info("✅ Sesión SAP B1 cerrada correctamente.")
|
||||
print("✅ Sesión SAP B1 cerrada correctamente.")
|
||||
else:
|
||||
logger.warning(f"⚠️ Logout respondió HTTP {resp.status_code}")
|
||||
print(f"⚠️ Logout respondió HTTP {resp.status_code}")
|
||||
except Exception as exc:
|
||||
logger.error(f"❌ Error al cerrar sesión: {exc}")
|
||||
print(f"❌ Error al cerrar sesión: {exc}")
|
||||
finally:
|
||||
_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)"
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
logger.warning(f"⚠️ {log_line} - {response.text[:300]}")
|
||||
print(f"⚠️ {log_line} - {response.text[:300]}")
|
||||
else:
|
||||
logger.info(log_line)
|
||||
print(log_line)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -136,7 +133,7 @@ def forward_to_sapb1(
|
||||
if extra_headers:
|
||||
req_headers.update(extra_headers)
|
||||
|
||||
logger.info(f" ├── 🔄 [{method}] {endpoint_path}")
|
||||
print(f" ├── 🔄 [{method}] {endpoint_path}")
|
||||
|
||||
try:
|
||||
resp = requests.request(
|
||||
@@ -156,7 +153,7 @@ def forward_to_sapb1(
|
||||
raise ConnectionError(f"Error en solicitud HTTP: {exc}")
|
||||
|
||||
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"]
|
||||
resp = requests.request(
|
||||
method=method,
|
||||
@@ -194,7 +191,7 @@ def forward_to_sapb1(
|
||||
def create_draft(payload: dict) -> dict:
|
||||
resp = forward_to_sapb1("POST", "Drafts", json_data=payload)
|
||||
result = resp.json()
|
||||
logger.info(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}")
|
||||
print(f" ├── ✅ Draft creado DocEntry={result.get('DocEntry')}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -204,7 +201,7 @@ def create_draft(payload: dict) -> dict:
|
||||
def get_purchase_invoice(doc_entry: int) -> dict:
|
||||
resp = forward_to_sapb1("GET", f"PurchaseInvoices({doc_entry})")
|
||||
result = resp.json()
|
||||
logger.info(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida")
|
||||
print(f" ├── ✅ PurchaseInvoices({doc_entry}) obtenida")
|
||||
return result
|
||||
|
||||
# ============================================================================
|
||||
@@ -252,7 +249,7 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
|
||||
entries = _get_delivery_entries(tipo, serie, numero)
|
||||
|
||||
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 []
|
||||
|
||||
# ── Fase 2: Obtener documento completo por cada DocEntry ─────────────
|
||||
@@ -263,20 +260,20 @@ def get_delivery_notes(tipo: str, serie: str, numero: str) -> list:
|
||||
try:
|
||||
resp = forward_to_sapb1("GET", f"{entidad}({doc_entry})")
|
||||
doc = resp.json()
|
||||
logger.info(f" │ └─ {entidad}({doc_entry}) obtenido")
|
||||
print(f" │ └─ {entidad}({doc_entry}) obtenido")
|
||||
|
||||
if entidad == "StockTransfers":
|
||||
num_ov = doc.get("U_IDC_NUMOV")
|
||||
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
|
||||
resp_ov = forward_to_sapb1("GET", f"Orders({num_ov})")
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.warning(f" │ ⚠️ Error obteniendo {entidad}({doc_entry}): {e}")
|
||||
print(f" │ ⚠️ Error obteniendo {entidad}({doc_entry}): {e}")
|
||||
|
||||
return docs
|
||||
|
||||
@@ -305,13 +302,13 @@ def _get_delivery_entries(tipo: str, serie: str, numero: str) -> list:
|
||||
if sl_entries:
|
||||
for sl_entry in sl_entries:
|
||||
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
|
||||
|
||||
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:
|
||||
logger.warning(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...")
|
||||
print(f" │ ⚠️ Guía {codigo}: SL falló ({e}). Consultando HANA...")
|
||||
|
||||
# ── Fallback a HANA ──────────────────────────────────────────────────
|
||||
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.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
|
||||
logger.info(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
|
||||
print(f" │ 🔍 Guía {codigo}: HANA → 0 registros")
|
||||
except Exception as e:
|
||||
logger.warning(f" │ ⚠️ Guía {codigo}: HANA falló ({e})")
|
||||
print(f" │ ⚠️ Guía {codigo}: HANA falló ({e})")
|
||||
|
||||
return []
|
||||
|
||||
@@ -364,7 +361,7 @@ def obtener_condicion_pago(card_code: str) -> dict | None:
|
||||
"NumberOfAdditionalDays": terms.get("NumberOfAdditionalDays") or 0,
|
||||
}
|
||||
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
|
||||
|
||||
# ============================================================================
|
||||
@@ -397,5 +394,5 @@ def get_zonas() -> list:
|
||||
)
|
||||
result = resp.json()
|
||||
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
|
||||
-187
@@ -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
|
||||
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):
|
||||
@@ -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()
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user