""" 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) # → "...
09T001-127773✅
" """ 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"{r['grflete']}{icono}{dp}{sap_price_str}{r['warehouse_code']}{r['label']}" return ( "" "" f"{rows}
GuíaEstadoDraft PriceSAPAlmacénResultado
" ) 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 ) # → "

✅ Draft SAP B1 creado exitosamente.

..." """ base = ( f"

Factura: {factura.get('serie')}-{factura.get('numero')}

" f"

Emisor: {factura.get('razon_social_emisor', '')}

" ) if status == "created": return f"

✅ Draft SAP B1 creado exitosamente.

DocEntry: {kwargs.get('doc_entry')}

{base}

Validación de precios

{validation_table}" elif status == "error": return f"

❌ Error al crear Draft SAP B1.

{base}

Error: {kwargs.get('error')}

Validación de precios

{validation_table}" else: return f"

⏭️ Draft NO creado. Los precios de las guías no coinciden con SAP.

{base}

Validación de precios

{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)