925 lines
40 KiB
Python
925 lines
40 KiB
Python
"""
|
||||
|
|
Pipeline de procesamiento de facturas de transporte.
|
|||
|
|
|
|||
|
|
Cada correo se procesa individualmente en un bucle:
|
|||
|
|
BD check → descargar adjuntos → descomprimir ZIPs → filtrar PDF/XML
|
|||
|
|
→ procesar PDFs (lookup) → procesar XMLs (cruzar con PDF)
|
|||
|
|
→ guardar resultado → construir draft SAP → validar precios
|
|||
|
|
→ crear draft en SAP (si válido) → notificar → guardar draft JSON
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import json
|
|||
|
|
import math
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
import email_utilities
|
|||
|
|
import outlook_utilities
|
|||
|
|
import database_utilities
|
|||
|
|
import sunat_schema
|
|||
|
|
import sapb1_manager
|
|||
|
|
import pdf_utilities
|
|||
|
|
|
|||
|
|
CUADRO_VALOR_REFERENCIAL = {
|
|||
|
|
"CHINCHA": 89.09,
|
|||
|
|
"PISCO": 101.7,
|
|||
|
|
"CAÑETE": 79.81,
|
|||
|
|
"TARMA": 106.54,
|
|||
|
|
"SAN CLEMENTE": 94.27,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# HELPERS
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
def _ruta_carpeta(subcarpeta: str) -> str:
|
|||
|
|
return os.path.join(os.path.dirname(__file__), subcarpeta)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# PASO 1: Autenticación Microsoft Graph
|
|||
|
|
# ============================================================================
|
|||
|
|
def step_authenticate() -> str | None:
|
|||
|
|
try:
|
|||
|
|
print("Intentando obtener token de acceso...")
|
|||
|
|
token = outlook_utilities.get_access_token()
|
|||
|
|
if token:
|
|||
|
|
print("✅ Token obtenido exitosamente.")
|
|||
|
|
print(f"Token prefix: {token[:25]}...")
|
|||
|
|
return token
|
|||
|
|
return None
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"❌ Error: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# PASO 2: Descarga de correos
|
|||
|
|
# ============================================================================
|
|||
|
|
def step_fetch_emails(token: str, email: str, fecha_inicio: str, fecha_fin: str) -> list[dict]:
|
|||
|
|
print("\n" + "=" * 60)
|
|||
|
|
print(f"[MAIL] {email}")
|
|||
|
|
try:
|
|||
|
|
emails = outlook_utilities.get_emails(email, token, fecha_inicio, fecha_fin)
|
|||
|
|
print(f"Se encontraron {len(emails)} correos.")
|
|||
|
|
return emails
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"❌ Error: {e}")
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# PROCESAR UN CORREO
|
|||
|
|
# ============================================================================
|
|||
|
|
def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folder: str, output_folder: str) -> None:
|
|||
|
|
"""
|
|||
|
|
Procesa un correo completo de principio a fin.
|
|||
|
|
|
|||
|
|
Flujo (pasos 1-14):
|
|||
|
|
1-2. Verifica en BD si el correo ya fue procesado; si no, lo guarda.
|
|||
|
|
3. Descarga adjuntos desde Microsoft Graph.
|
|||
|
|
4. Descomprime archivos ZIP.
|
|||
|
|
5. Filtra solo archivos PDF/XML.
|
|||
|
|
6. Procesa PDFs (extrae datos, renombra, construye cache).
|
|||
|
|
7. Procesa XMLs (cruza con PDF, asigna guías de remisión).
|
|||
|
|
8. Guarda resultado JSON con datos extraídos.
|
|||
|
|
9. Filtra solo facturas tipo 01 para SAP.
|
|||
|
|
10. Valida precios contra DeliveryNotes en SAP.
|
|||
|
|
11. Construye payload del draft SAP B1.
|
|||
|
|
12. Guarda draft JSON para depuración.
|
|||
|
|
13. Crea el draft en SAP (si los precios coinciden) o notifica error.
|
|||
|
|
14. Envía email de notificación con el resultado.
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
email: Diccionario del correo (debe tener ``id``, ``fecha``,
|
|||
|
|
``subject``).
|
|||
|
|
token: Token de acceso Microsoft Graph.
|
|||
|
|
email_addr: Dirección de correo de la bandeja de entrada.
|
|||
|
|
download_folder: Carpeta relativa para descargar adjuntos.
|
|||
|
|
output_folder: Carpeta relativa para guardar resultados.
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
None. Los resultados se persisten a disco (JSON) y se envían
|
|||
|
|
notificaciones por email.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def _descargar_adjuntos() -> 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()
|
|||
|
|
# → ["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:
|
|||
|
|
print(f" ❌ Error descargando adjuntos: {ex}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def _descomprimir() -> None:
|
|||
|
|
"""
|
|||
|
|
Descomprime archivos ZIP entre los adjuntos del correo.
|
|||
|
|
|
|||
|
|
Los archivos extraídos se colocan en la misma carpeta de descarga.
|
|||
|
|
Los ZIP originales no se eliminan automáticamente.
|
|||
|
|
|
|||
|
|
Example:
|
|||
|
|
Si hay ``adjuntos.zip`` con ``factura.pdf`` y ``guia.xml``::
|
|||
|
|
|
|||
|
|
_descomprimir()
|
|||
|
|
# → Aparecen factura.pdf y guia.xml en download_folder
|
|||
|
|
"""
|
|||
|
|
email_utilities.descomprimir_adjuntos([email], download_folder)
|
|||
|
|
|
|||
|
|
def _filtrar_y_limpiar() -> 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()
|
|||
|
|
# → 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)
|
|||
|
|
print(f" ├── 🗑️ 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
|
|||
|
|
|
|||
|
|
def _procesar_pdfs() -> 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()
|
|||
|
|
# → {"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
|
|||
|
|
print(f" │ 📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})")
|
|||
|
|
else:
|
|||
|
|
print(f" │ ⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}")
|
|||
|
|
|
|||
|
|
if datos_pdf:
|
|||
|
|
email["datos_pdf"] = datos_pdf
|
|||
|
|
|
|||
|
|
return pdf_cache
|
|||
|
|
|
|||
|
|
def _procesar_xmls(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.
|
|||
|
|
|
|||
|
|
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(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 = []
|
|||
|
|
|
|||
|
|
for idx, a in enumerate(email["adjuntos"]):
|
|||
|
|
prefijo = " │ └── " if idx == len(email["adjuntos"]) - 1 else " │ ├── "
|
|||
|
|
print(f"{prefijo}{a}")
|
|||
|
|
|
|||
|
|
if not a.lower().endswith(".xml"):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
xml_path = os.path.join(carpeta, a)
|
|||
|
|
print(f" │ 📄 Procesando XML: {a}")
|
|||
|
|
data = sunat_schema.extraer_datos_xml(xml_path)
|
|||
|
|
|
|||
|
|
if not data:
|
|||
|
|
print(f" │ ℹ️ XML sin datos (no es factura tipo 01 o guía 31)")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
data["ruta_xml"] = xml_path
|
|||
|
|
|
|||
|
|
# ruta_pdf: construir nombre canónico {ruc}-{tipo}-{serie}-{numero}.pdf
|
|||
|
|
# El número ya viene con lstrip("0") desde _split_serie_numero en sunat_schema
|
|||
|
|
# y generar_nombre_pdf ahora también aplica lstrip("0") para que coincidan
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
# 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
|
|||
|
|
print(f" │ 🔄 documentos_referencia completados desde PDF ({len(refs)} refs)")
|
|||
|
|
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
|
|||
|
|
print(f" │ 🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)")
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# Asignar guias_remision[] y guia_transportista a cada línea
|
|||
|
|
# Primero: todas las refs tipo 09 y 31 desde 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"]
|
|||
|
|
# Segundo: si hay datos del PDF, usar agrupación por ítem (respeta multi-guía)
|
|||
|
|
pdf_items = (pdf_cache.get(pdf_canonical) or {}).get("items", [])
|
|||
|
|
for i, linea in enumerate(data.get("lineas", [])):
|
|||
|
|
if i < len(pdf_items):
|
|||
|
|
pi = pdf_items[i]
|
|||
|
|
linea["guias_remision"] = []
|
|||
|
|
for g_str in pi.get("guias_remision", []):
|
|||
|
|
m = re.search(r"\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b", g_str)
|
|||
|
|
if m:
|
|||
|
|
linea["guias_remision"].append({
|
|||
|
|
"tipo": "09", "serie": m.group(1).upper(), "numero": m.group(2),
|
|||
|
|
})
|
|||
|
|
else:
|
|||
|
|
partes = g_str.split("-", 1)
|
|||
|
|
if len(partes) == 2:
|
|||
|
|
linea["guias_remision"].append({
|
|||
|
|
"tipo": "09", "serie": partes[0].strip(), "numero": partes[1].strip(),
|
|||
|
|
})
|
|||
|
|
guia_transportista = pi.get("guia_transportista")
|
|||
|
|
if guia_transportista:
|
|||
|
|
m = re.search(r"\b([TVE][A-Z0-9]{3})\s*-\s*(\d+)\b", guia_transportista)
|
|||
|
|
if m:
|
|||
|
|
pi["guia_transportista_serie"] = m.group(1)
|
|||
|
|
pi["guia_transportista_numero"] = m.group(2)
|
|||
|
|
elif "-" in guia_transportista:
|
|||
|
|
partes_list = guia_transportista.split("-", 1)
|
|||
|
|
linea["guia_transportista"] = {"tipo": "31", "serie": partes_list[0].strip(), "numero": partes_list[1].strip()}
|
|||
|
|
else:
|
|||
|
|
linea["guia_transportista"] = None
|
|||
|
|
else:
|
|||
|
|
linea["guia_transportista"] = None
|
|||
|
|
elif i < len(refs_09):
|
|||
|
|
r = refs_09[i]
|
|||
|
|
linea["guias_remision"] = [{"tipo": "09", "serie": r["serie"].strip(), "numero": r["numero"].strip()}]
|
|||
|
|
linea["guia_transportista"] = refs_31[i] if i < len(refs_31) else None
|
|||
|
|
else:
|
|||
|
|
linea["guias_remision"] = []
|
|||
|
|
linea["guia_transportista"] = None
|
|||
|
|
|
|||
|
|
print(f" │ ✅ Datos extraídos: {data.get('serie')}-{data.get('numero')} ({data.get('tipo_descripcion', '?')})")
|
|||
|
|
documentos.append(data)
|
|||
|
|
|
|||
|
|
return documentos
|
|||
|
|
|
|||
|
|
def _guardar_resultado(documentos: list[dict]) -> None:
|
|||
|
|
"""
|
|||
|
|
Guarda la lista de documentos extraídos en un archivo JSON.
|
|||
|
|
|
|||
|
|
El nombre del archivo sigue el patrón
|
|||
|
|
``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``).
|
|||
|
|
|
|||
|
|
Example:
|
|||
|
|
Si ``documentos[0]`` tiene ``serie="F002", numero="486"``::
|
|||
|
|
|
|||
|
|
_guardar_resultado(documentos)
|
|||
|
|
# → Crea 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")
|
|||
|
|
filename = f"resultado_{serie}-{numero}_{ts}.json"
|
|||
|
|
filepath = os.path.join(output_folder, filename)
|
|||
|
|
with open(filepath, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(documentos, f, ensure_ascii=False, indent=4)
|
|||
|
|
print(f" 💾 Resultado guardado: {filename}")
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
``total_weight × CUADRO_VALOR_REFERENCIAL[zona]``
|
|||
|
|
donde ``total_weight`` es la suma de ``Weight1`` de todos los
|
|||
|
|
DeliveryNotes de la guía, y ``zona`` se obtiene del campo ``U_IDC_DESTINO`` del primer
|
|||
|
|
DeliveryNote de la primera guía, mapeado contra el maestro de
|
|||
|
|
zonas SAP (``U_IDC_ZONAS``).
|
|||
|
|
|
|||
|
|
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``, ``use_sap_prices``, 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", "")
|
|||
|
|
|
|||
|
|
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"
|
|||
|
|
|
|||
|
|
neto = round(total - monto_detraccion, 2)
|
|||
|
|
if tiene_detraccion:
|
|||
|
|
installments = [
|
|||
|
|
{"DueDate": fecha_emision, "Total": round(monto_detraccion, 2), "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
|
|||
|
|
{"DueDate": fecha_emision, "Total": neto, "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
|
|||
|
|
]
|
|||
|
|
else:
|
|||
|
|
installments = [
|
|||
|
|
{"DueDate": fecha_emision, "Total": round(total, 2), "InstallmentId": 1, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
doc_lines = []
|
|||
|
|
total_weight = 0
|
|||
|
|
zona_guia = ""
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
|
|||
|
|
zona_guia = guias_lt[0].get("zona","")
|
|||
|
|
|
|||
|
|
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)))
|
|||
|
|
|
|||
|
|
total_weight += item.get("weight1", 0)
|
|||
|
|
|
|||
|
|
doc_lines.append({
|
|||
|
|
"ItemCode": item_code,
|
|||
|
|
#"ItemDescription": linea.get("descripcion", "TRANSPORTE DE CARGA MERCADERIA")[:200],
|
|||
|
|
"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 = round((total_weight / 1000 )* (CUADRO_VALOR_REFERENCIAL.get(zona_guia, 0)),2)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"DocType": "dDocument_Items",
|
|||
|
|
"DocDate": fecha_contabilizacion,
|
|||
|
|
"DocDueDate": fecha_emision,
|
|||
|
|
"TaxDate": fecha_emision,
|
|||
|
|
"CardCode": f"PL{ruc_emisor}",
|
|||
|
|
"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_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,
|
|||
|
|
"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. Decide si se deben usar precios de SAP en el draft
|
|||
|
|
(``use_sap_prices``):
|
|||
|
|
``ok and int(cantidad) == 1 and int(total_sap_qty) != 1``.
|
|||
|
|
|
|||
|
|
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``, ``warehouse_code``, ``draft_cantidad``,
|
|||
|
|
``use_sap_prices``, ``guias_lt[]``.
|
|||
|
|
|
|||
|
|
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", [])
|
|||
|
|
|
|||
|
|
zonas_raw = sapb1_manager.get_zonas()
|
|||
|
|
zonas = {z["Code"]: z["Name"] for z in zonas_raw}
|
|||
|
|
|
|||
|
|
for fl in factura_lines:
|
|||
|
|
cantidad = float(fl.get("cantidad", 0))
|
|||
|
|
total_linea = float(fl.get("total_linea", 0) or 0)
|
|||
|
|
guias = fl.get("guias_remision", [])
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
for guia in guias:
|
|||
|
|
codigo_guia = f"{guia['tipo']}{guia['serie']}-{guia['numero']}"
|
|||
|
|
try:
|
|||
|
|
delivery_notes = sapb1_manager.get_delivery_notes(
|
|||
|
|
tipo=guia["serie"], serie=guia["tipo"], numero=guia["numero"]
|
|||
|
|
)
|
|||
|
|
print(f" │ 🔍 Guía {codigo_guia}: {len(delivery_notes)} documento(s) encontrado(s) en SAP")
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
zona = zonas.get(dn.get("U_IDC_DESTINO",""),"")
|
|||
|
|
|
|||
|
|
first_line = dn.get("DocumentLines", [None])[0]
|
|||
|
|
if first_line:
|
|||
|
|
wh = first_line.get("WarehouseCode", "")
|
|||
|
|
if wh:
|
|||
|
|
wh_set.add(wh)
|
|||
|
|
weight1 += float(first_line.get("Weight1", 0) or 0)
|
|||
|
|
for linea_detalle_dict in dn.get("DocumentLines", []):
|
|||
|
|
if not re.match(r"FL\d", linea_detalle_dict.get("ItemCode", "")):
|
|||
|
|
continue
|
|||
|
|
line_total = float(linea_detalle_dict.get("LineTotal", 0) or 0)
|
|||
|
|
price_after_vat = float(linea_detalle_dict.get("PriceAfterVAT", 0) or 0)
|
|||
|
|
quantity = float(linea_detalle_dict.get("Quantity", 0) or 0)
|
|||
|
|
suma_line_total += line_total
|
|||
|
|
max_qty = max(max_qty, quantity)
|
|||
|
|
sum_pav += price_after_vat
|
|||
|
|
print(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}")
|
|||
|
|
|
|||
|
|
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:
|
|||
|
|
print(f" │ ⚠️ Guía {codigo_guia}: error al consultar SAP - {ex}")
|
|||
|
|
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)
|
|||
|
|
ok = all_ok and abs(total_linea - sap_total) < 0.01
|
|||
|
|
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": "LineTotal (FL*) vs total_linea",
|
|||
|
|
"warehouse_code": wh_str,
|
|||
|
|
"guias_lt": guias_lt,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
print(f" │ Cantidad: {cantidad} | Total LineTotal (SAP): {sap_total:.6f} vs total_linea (XML): {total_linea:.6f}")
|
|||
|
|
if ok:
|
|||
|
|
print(f" │ ✅ Coincide: {total_linea:.6f} ≈ {sap_total:.6f}")
|
|||
|
|
else:
|
|||
|
|
print(f" │ ❌ NO coincide — XML: {total_linea:.6f} vs SAP: {sap_total:.6f}")
|
|||
|
|
|
|||
|
|
todas_coinciden = all(r["ok"] for r in resultados)
|
|||
|
|
return todas_coinciden, resultados
|
|||
|
|
|
|||
|
|
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_notification_body(status: str, factura: dict, validation_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``.
|
|||
|
|
**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, 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>"
|
|||
|
|
)
|
|||
|
|
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}"
|
|||
|
|
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}"
|
|||
|
|
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}"
|
|||
|
|
|
|||
|
|
def _guardar_draft_json(draft: dict, serie: str, numero: 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")
|
|||
|
|
# → 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)
|
|||
|
|
print(f" 💾 Draft guardado: {filename}")
|
|||
|
|
|
|||
|
|
# ========================================================================
|
|||
|
|
# FLUJO DEL CORREO
|
|||
|
|
# ========================================================================
|
|||
|
|
|
|||
|
|
print(f"\n📬 [{email['fecha']}] {email['subject'][:80]}...")
|
|||
|
|
print(f" ├── 📂 Adjuntos:")
|
|||
|
|
|
|||
|
|
# 1-2. BD: verificar existencia y guardar
|
|||
|
|
exists = database_utilities.get_existing_ids([email["id"]])
|
|||
|
|
if email["id"] in exists:
|
|||
|
|
print(f" ├── ⏭️ Correo ya procesado, saltando")
|
|||
|
|
return
|
|||
|
|
database_utilities.save_emails([email])
|
|||
|
|
|
|||
|
|
# 3. Descargar adjuntos
|
|||
|
|
adjuntos = _descargar_adjuntos()
|
|||
|
|
if not adjuntos:
|
|||
|
|
print(f" ├── ⏭️ Correo sin adjuntos")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 4. Descomprimir ZIPs
|
|||
|
|
_descomprimir()
|
|||
|
|
|
|||
|
|
# 5. Filtrar PDF/XML y limpiar si no hay válidos
|
|||
|
|
if not _filtrar_y_limpiar():
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 6. Procesar PDFs
|
|||
|
|
pdf_cache = _procesar_pdfs()
|
|||
|
|
|
|||
|
|
# 7. Procesar XMLs
|
|||
|
|
documentos = _procesar_xmls(pdf_cache)
|
|||
|
|
if not documentos:
|
|||
|
|
print(f" ├── ⏭️ No se extrajeron documentos XML")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 8. Guardar resultado con todos los documentos
|
|||
|
|
_guardar_resultado(documentos)
|
|||
|
|
|
|||
|
|
# 9. Filtrar solo facturas para SAP
|
|||
|
|
facturas = [d for d in documentos if d.get("tipo") == "01"]
|
|||
|
|
if not facturas:
|
|||
|
|
print(f" ├── ⏭️ No hay facturas (tipo 01) para SAP")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
for factura in facturas:
|
|||
|
|
serie = factura.get("serie", "?")
|
|||
|
|
numero = factura.get("numero", "?")
|
|||
|
|
|
|||
|
|
# 10. Validar precios (consulta SAP DNs, NO requiere draft)
|
|||
|
|
todas_coinciden, resultados_val = _validate_prices(factura)
|
|||
|
|
validation_table = _build_validation_html(resultados_val)
|
|||
|
|
|
|||
|
|
# 11. Construir draft (usa LineTotal de los DNs, no del XML)
|
|||
|
|
draft = _build_sap_draft(factura, resultados_val)
|
|||
|
|
|
|||
|
|
adjuntos_email = [p for p in [factura.get("ruta_xml"), factura.get("ruta_pdf")] if p]
|
|||
|
|
|
|||
|
|
# 12. Guardar draft JSON (siempre, para depuración)
|
|||
|
|
_guardar_draft_json(draft, serie, numero)
|
|||
|
|
|
|||
|
|
# 13. Crear draft en SAP o generar HTML
|
|||
|
|
if todas_coinciden:
|
|||
|
|
try:
|
|||
|
|
sap_result = sapb1_manager.create_draft(draft)
|
|||
|
|
doc_entry = sap_result.get("DocEntry")
|
|||
|
|
print(f" ✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
|
|||
|
|
html_body = _build_notification_body("created", factura, validation_table, doc_entry=doc_entry)
|
|||
|
|
except Exception as ex:
|
|||
|
|
print(f" ❌ Error creando draft {serie}-{numero}: {ex}")
|
|||
|
|
html_body = _build_notification_body("error", factura, validation_table, error=ex)
|
|||
|
|
else:
|
|||
|
|
print(f" ⏭️ Draft NO creado: {serie}-{numero} — validación falló")
|
|||
|
|
html_body = _build_notification_body("skipped", factura, validation_table)
|
|||
|
|
|
|||
|
|
# 14. Enviar notificación
|
|||
|
|
if token:
|
|||
|
|
outlook_utilities.send_email(
|
|||
|
|
token, email_addr,
|
|||
|
|
["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
|
|||
|
|
f"Draft SAP B1 - {serie}-{numero}",
|
|||
|
|
html_body, adjuntos_email,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# PIPELINE COMPLETO
|
|||
|
|
# ============================================================================
|
|||
|
|
def run_pipeline(dias: int, EMAIL: str, DOWNLOAD_FOLDER: str, OUTPUT_FOLDER: str) -> None:
|
|||
|
|
"""Ejecuta el pipeline completo: autentica, descarga correos y procesa cada uno."""
|
|||
|
|
|
|||
|
|
database_utilities.init_db()
|
|||
|
|
|
|||
|
|
hoy = datetime.now()
|
|||
|
|
ayer = hoy - timedelta(days=dias)
|
|||
|
|
fecha_inicio = ayer.strftime("%Y-%m-%d")
|
|||
|
|
fecha_fin = hoy.strftime("%Y-%m-%d")
|
|||
|
|
print(f"Rango de búsqueda: {fecha_inicio} al {fecha_fin}")
|
|||
|
|
|
|||
|
|
# 1. Autenticar
|
|||
|
|
token = step_authenticate()
|
|||
|
|
if not token:
|
|||
|
|
print("❌ No se pudo autenticar. Abortando.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 2. Descargar correos
|
|||
|
|
emails = step_fetch_emails(token, EMAIL, fecha_inicio, fecha_fin)
|
|||
|
|
if not emails:
|
|||
|
|
print("No se encontraron correos.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 3. Bucle por cada correo
|
|||
|
|
for email in emails:
|
|||
|
|
_procesar_un_correo(email, token, EMAIL, DOWNLOAD_FOLDER, OUTPUT_FOLDER)
|