se agrega integración con SAP HANA, manejo de errores en el demonio y actualización de documentación

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5 committed 2026-09-02 07:40:26 -05:00
1 parent 3c2bb2b714
commit 911bd2da34
14 files changed
+1711 -237

No files matched your search

+393 -128
View File
@@ -20,14 +20,7 @@ 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,
}
import hana_manager
# ============================================================================
@@ -104,6 +97,11 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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.
@@ -130,7 +128,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
return adjuntos
return None
except Exception as ex:
print(f" ❌ Error descargando adjuntos: {ex}")
_log(f"❌ Error descargando adjuntos: {ex}")
return None
def _descomprimir() -> None:
@@ -179,7 +177,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
ruta = os.path.join(carpeta, a)
if os.path.exists(ruta):
os.remove(ruta)
print(f" ├── 🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
_log("🗑️ Correo sin PDF/XML válidos, adjuntos eliminados")
return False
for a in no_validos:
@@ -236,9 +234,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
canonical = os.path.basename(nuevo_path)
pdf_cache[canonical] = data
print(f" │ 📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})")
_log(f"📄 PDF procesado: {canonical} ({data.get('tipo_documento', '?')})", 3)
else:
print(f" │ ⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}")
_log(f"⚠️ PDF no procesado: {adjunto} - {data.get('error', 'error desconocido')}", 2)
if datos_pdf:
email["datos_pdf"] = datos_pdf
@@ -256,7 +254,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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.
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
@@ -277,26 +276,49 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
carpeta = _ruta_carpeta(download_folder)
documentos = []
last_idx = len(email["adjuntos"]) - 1
for idx, a in enumerate(email["adjuntos"]):
prefijo = " │ └── " if idx == len(email["adjuntos"]) - 1 else " │ ├── "
print(f"{prefijo}{a}")
_log(a, 2, last=(idx == last_idx))
if not a.lower().endswith(".xml"):
continue
xml_path = os.path.join(carpeta, a)
print(f" │ 📄 Procesando XML: {a}")
_log(f"📄 Procesando XML: {a}", 3)
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)")
_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
# 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):
@@ -312,93 +334,257 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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)")
_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
print(f" │ 🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)")
_log(f"🔄 documentos_referencia completados desde PDF (fallback, {len(refs)} refs)", 3)
break
# Asignar guias_remision[] y guia_transportista a cada línea
# Primero: todas las refs tipo 09 y 31 desde documentos_referencia
# 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"]
# 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', '?')})")
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 _guardar_resultado(documentos: list[dict]) -> None:
def _completar_guias(documentos: list[dict], pdf_cache: dict) -> int:
"""
Guarda la lista de documentos extraídos en un archivo JSON.
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
``resultado_{serie}-{numero}_{timestamp}.json`` y se escribe en
``{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)
# → Crea resultado_F002-486_2026-07-08_12-00-00.json
_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
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:
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)
print(f" 💾 Resultado guardado: {filename}")
_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": raw.get("nombre_receptor"),
"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 _build_sap_draft(factura: dict, resultados_val: list[dict]) -> dict:
"""
@@ -420,12 +606,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
- ``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``).
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
@@ -475,11 +658,14 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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:
# neto = round(total - monto_detraccion, 2)
monto_detraccion = math.ceil(monto_detraccion)
neto = total - monto_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": ""},
{"DueDate": fecha_emision, "Total": monto_detraccion, "InstallmentId": 1, "U_VS_TIPOCUOTA": "D", "U_VS_TIPOSERVICIO": f"{codigo_detraccion}02"},
{"DueDate": fecha_emision, "Total": round(neto,2), "InstallmentId": 2, "U_VS_TIPOCUOTA": "N", "U_VS_TIPOSERVICIO": ""},
]
else:
installments = [
@@ -487,8 +673,6 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
]
doc_lines = []
total_weight = 0
zona_guia = ""
guia_referencia = ""
for i, linea in enumerate(factura.get("lineas", [])):
@@ -497,18 +681,13 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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],
@@ -529,7 +708,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
})
valor_referencial = round((total_weight / 1000 )* (CUADRO_VALOR_REFERENCIAL.get(zona_guia, 0)),2)
valor_referencial = sum(l.get("valor_referencial") or 0 for l in factura.get("lineas", []))
return {
"DocType": "dDocument_Items",
@@ -544,6 +723,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
"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",
@@ -649,14 +829,9 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
for guia in guias:
codigo_guia = f"{guia['tipo']}{guia['serie']}-{guia['numero']}"
try:
# consulta a sap por cada guia de remision
# esto nos puede retornar 0, 1 o mas documentos
# lo ideal es que nos retorne 0 o 1 documento
delivery_notes = sapb1_manager.get_delivery_notes(
tipo=guia["serie"], serie=guia["tipo"], numero=guia["numero"]
)
# cantidad de documentos encontrados en sap
print(f" │ 🔍 Guía {codigo_guia}: {len(delivery_notes)} documento(s) encontrado(s) en SAP")
wh_set = set()
suma_line_total = 0.0
@@ -665,7 +840,6 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
weight1 = 0.0
zona = ""
# recorremos cada documento encontrado
for dn in delivery_notes:
# obtenemos la zona de destino del documento
zona = zonas.get(dn.get("U_IDC_DESTINO",""),"")
@@ -697,17 +871,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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}")
_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:
print(f" │ ⚠️ Guía {codigo_guia}: error al consultar SAP - {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"
@@ -724,16 +900,19 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
"ok": ok,
"draft_price": total_linea,
"sap_price": sap_total,
"label": "LineTotal (FL*) vs total_linea",
"label": label,
"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}")
if sin_sap:
_log("⏭️ Sin guías en SAP — línea saltada", 3)
else:
print(f" │ ❌ NO coincide — XML: {total_linea:.6f} vs SAP: {sap_total:.6f}")
_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
@@ -768,7 +947,72 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
f"{rows}</table>"
)
def _build_notification_body(status: str, factura: dict, validation_table: str, **kwargs) -> str:
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.
@@ -781,6 +1025,8 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
``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ó.
@@ -792,7 +1038,7 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
Draft creado exitosamente::
body = _build_notification_body(
"created", factura, tabla_html, doc_entry=2021
"created", factura, tabla_html, valref_html, doc_entry=2021
)
# → "<p>✅ Draft SAP B1 creado exitosamente.</p>..."
"""
@@ -800,12 +1046,13 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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}"
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}"
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}"
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:
"""
@@ -833,26 +1080,26 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
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}")
_log(f"💾 Draft guardado: {filename}")
# ========================================================================
# FLUJO DEL CORREO
# ========================================================================
print(f"\n📬 [{email['fecha']}] {email['subject'][:80]}...")
print(f" ├── 📂 Adjuntos:")
_log("📂 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")
_log("⏭️ Correo ya procesado, saltando")
return
database_utilities.save_emails([email])
# 3. Descargar adjuntos
adjuntos = _descargar_adjuntos()
if not adjuntos:
print(f" ├── ⏭️ Correo sin adjuntos")
_log("⏭️ Correo sin adjuntos")
return
# 4. Descomprimir ZIPs
@@ -868,49 +1115,67 @@ def _procesar_un_correo(email: dict, token: str, email_addr: str, download_folde
# 7. Procesar XMLs
documentos = _procesar_xmls(pdf_cache)
if not documentos:
print(f" ├── ⏭️ No se extrajeron documentos XML")
_log("⏭️ No se extrajeron documentos XML")
return
# 8. Guardar resultado con todos los documentos
_guardar_resultado(documentos)
# 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. Filtrar solo facturas para SAP
# 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:
print(f" ├── ⏭️ No hay facturas (tipo 01) para SAP")
_log("⏭️ 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)
_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)
# 11. Construir draft (usa LineTotal de los DNs, no del XML)
# 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)
# 12. Guardar draft JSON (siempre, para depuración)
# 14. Guardar draft JSON (siempre, para depuración)
_guardar_draft_json(draft, serie, numero)
# 13. Crear draft en SAP o generar HTML
# 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")
print(f" ✅ Draft SAP B1 creado: {serie}-{numero} → DocEntry {doc_entry}")
html_body = _build_notification_body("created", factura, validation_table, doc_entry=doc_entry)
_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:
print(f" ❌ Error creando draft {serie}-{numero}: {ex}")
html_body = _build_notification_body("error", factura, validation_table, error=ex)
_log(f"❌ Error creando draft {serie}-{numero}: {ex}")
html_body = _build_notification_body("error", factura, validation_table, valor_referencial_table, error=ex)
else:
print(f" ⏭️ Draft NO creado: {serie}-{numero} — validación falló")
html_body = _build_notification_body("skipped", factura, validation_table)
_log(f"⏭️ Draft NO creado: {serie}-{numero} — validación falló")
html_body = _build_notification_body("skipped", factura, validation_table, valor_referencial_table)
# 14. Enviar notificación
# 16. Enviar notificación
if token:
outlook_utilities.send_email(
token, email_addr,