Files
georgeandClaude Sonnet 5 c5210c8a57 se usa fecha_vencimiento en DueDate de las cuotas del draft
Las cuotas (DocumentInstallments) ahora usan la misma fecha_vencimiento
calculada para DocDueDate, en vez de fecha_emision del XML.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 09:32:11 -05:00

1333 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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
import shutil
import ntpath
from datetime import datetime, timedelta
import email_utilities
import outlook_utilities
import database_utilities
import sunat_schema
import sapb1_manager
import pdf_utilities
import hana_manager
RUTA_RED_FACTURACION = os.getenv("RUTA_RED_FACTURACION", "")
RUC_EMPRESA = os.getenv("RUC_EMPRESA", "")
# ============================================================================
# HELPERS
# ============================================================================
def _ruta_carpeta(subcarpeta: str) -> str:
return os.path.join(os.path.dirname(__file__), subcarpeta)
def _copiar_a_red(data: dict, _log) -> 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``).
"""
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)
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)
# ============================================================================
# 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 _log(msg: str, level: int = 1, last: bool = False) -> None:
branch = " └── " if last else " ├── "
pfx = {1: branch, 2: f" │ {branch}", 3: f" │ │ {branch}", 4: f" │ │ │ {branch}"}.get(level, branch)
print(f"{pfx}{msg}")
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:
_log(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)
_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
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
_log(f"📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})", 3)
else:
_log(f"⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}", 2)
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 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(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, _log)
# 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)
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)
def _guardar_resultado(documentos: list[dict], datos_pdf: list[dict]) -> 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)
# → 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 _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``, ``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", "")
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. 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", [])
# 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)
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)
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 _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
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}"
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)
_log(f"💾 Draft guardado: {filename}")
# ========================================================================
# FLUJO DEL CORREO
# ========================================================================
print(f"\n📬 [{email['fecha']}] {email['subject'][:80]}...")
_log("📂 Adjuntos:")
# 1-2. BD: verificar existencia y guardar
exists = database_utilities.get_existing_ids([email["id"]])
if email["id"] in exists:
_log("⏭️ Correo ya procesado, saltando")
return
database_utilities.save_emails([email])
# 3. Descargar adjuntos
adjuntos = _descargar_adjuntos()
if not adjuntos:
_log("⏭️ 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:
_log("⏭️ No se extrajeron documentos XML")
return
# 8. Completar guías faltantes desde PDF
n_guias = _completar_guias(documentos, pdf_cache)
if n_guias:
_log(f"🔄 {n_guias} guías completadas desde PDF")
# 9. Validar que toda guía tenga tipo, serie y número
_validar_guias(documentos)
# 10. Guardar resultado con todos los documentos
paths_resultado = _guardar_resultado(documentos, email.get("datos_pdf", []))
email["resultado_paths"] = paths_resultado
# 11. Filtrar solo facturas para SAP
facturas = [d for d in documentos if d.get("tipo") == "01"]
if not facturas:
_log("⏭️ No hay facturas (tipo 01) para SAP")
return
for factura in facturas:
serie = factura.get("serie", "?")
numero = factura.get("numero", "?")
_log(f"📄 Factura: {serie}-{numero}")
# 12. Validar precios (consulta SAP DNs, NO requiere draft)
todas_coinciden, resultados_val = _validate_prices(factura)
validation_table = _build_validation_html(resultados_val)
# 12.5 Comparar valor referencial del XML vs SAP (Sp_ObtenerValorReferencial)
guias_valor_ref = _obtener_valor_referencial_sap(factura)
valor_referencial_table = _build_valor_referencial_html(factura, guias_valor_ref)
# 13. 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]
for p in email.get("resultado_paths", []):
if p and os.path.exists(p) and p not in adjuntos_email:
adjuntos_email.append(p)
# 14. Guardar draft JSON (siempre, para depuración)
_guardar_draft_json(draft, serie, numero)
# 15. Crear draft en SAP o generar HTML
if todas_coinciden:
try:
sap_result = sapb1_manager.create_draft(draft)
doc_entry = sap_result.get("DocEntry")
_log(f"✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
html_body = _build_notification_body("created", factura, validation_table, valor_referencial_table, doc_entry=doc_entry)
except Exception as ex:
_log(f"❌ Error creando draft {serie}-{numero}: {ex}")
html_body = _build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
else:
_log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
html_body = _build_notification_body("skipped", factura, validation_table, valor_referencial_table)
# 16. Enviar notificación al remitente original, con copia al equipo
if token:
destinatario = email.get("sender")
if destinatario and destinatario != "Desconocido":
outlook_utilities.send_email(
token, email_addr,
[destinatario],
f"Draft SAP B1 - {serie}-{numero}",
html_body, adjuntos_email,
cc_recipients=["jlopez@aberio.com.pe", "gcalle@pucp.pe"],
)
else:
_log("⚠️ No se pudo determinar el remitente, notificación no enviada")
# ============================================================================
# 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)