import json import os import shutil from pathlib import Path from datetime import datetime from dotenv import load_dotenv from lxml import etree from jinja2 import Environment, FileSystemLoader from logger_manager import get_logger # ============================================================================ _BASE = Path(__file__).resolve().parent # load_dotenv(_BASE / ".env") # load_dotenv(_BASE.parent / ".env") load_dotenv(dotenv_path=".env") INPUT_DIR = Path(os.getenv("INPUT_DIR", "data/input")) PROCESSED_DIR = Path("data/processed") OUTPUT_DIR = Path("data/output") HTML_DIR = Path("data/html") SAP_B1_DIR = Path("data/sap_b1") ERRORS_DIR = Path("data/errors") TEMPLATE_FILE = _BASE / "template.html" POST_TO_SAP_B1 = True TEXT_MONTHS = { 1: "enero", 2: "febrero", 3: "marzo", 4: "abril", 5: "mayo", 6: "junio", 7: "julio", 8: "agosto", 9: "septiembre", 10: "octubre", 11: "noviembre", 12: "diciembre", } # ============================================================================ log = get_logger() # ============================================================================ def _format_date(d: str) -> str: if not d: return "" p = d.split("-") if len(p) == 3: return f"{p[2][:2]} de {TEXT_MONTHS.get(int(p[1]), p[1])} de {p[0]}" return d # ============================================================================ def _format_datetime(dt: str) -> str: if not dt: return "" dp = dt.split("T")[0] tp = dt.split("T")[1][:5] if "T" in dt else "" fd = _format_date(dp) return f"{fd} {tp} hrs." if tp else fd # ============================================================================ def _event_text(ev: str) -> str: return {"DATE_OF_INVOICE": "fecha de factura", "RECEIPT_OF_GOODS": "recepción de mercadería"}.get(ev, ev or "") # ============================================================================ def apply_allowances(net_price: int, qty: int, allowances: list) -> tuple: total = net_price * qty applied = [] for alw in allowances: pct = alw.get("percentage", 0) if pct > 0: dsc = round(total * pct / 100) else: dsc = round(alw.get("amount", 0)) applied.append({"type": alw.get("type"), "percentage": pct, "amount": dsc}) total -= dsc return total, applied # ============================================================================ def _strip_ns(root): for el in root.iter(): if "}" in el.tag: el.tag = el.tag.split("}", 1)[1] etree.cleanup_namespaces(root) return root # ============================================================================ def _x(node, path, default=None): v = node.xpath(path) return v[0] if v else default # ============================================================================ def _t(node, path, default=""): v = _x(node, path) return v.text.strip() if v is not None and v.text else default # ============================================================================ def _a(node, path, attr, default=""): v = _x(node, path) return v.get(attr, default) if v is not None else default # ============================================================================ def _l(node, path): return node.xpath(path) # ============================================================================ def substring_desde_fin(texto: str, cantidad: int = 10, excluir_ultimos: int = 1) -> str: inicio = -(cantidad + excluir_ultimos) fin = -excluir_ultimos if excluir_ultimos > 0 else None return texto[inicio:fin] # ============================================================================ def parse_order(xml_bytes: bytes, filename: str = "") -> dict: root = _strip_ns(etree.fromstring(xml_bytes)) order = _x(root, "//order") if order is None: raise ValueError("Elemento no encontrado") msg = _x(root, "//messageHeader") oc_id = _x( order, 'typedEntityIdentification[@entityType="ORDER"]/entityIdentification/uniqueCreatorIdentification', ) oc_num = oc_id.text.strip() if oc_id is not None and oc_id.text else "" cd = order.get("creationDate", "") pt = _x(order, "paymentTerms") event = pt.get("eventType", "") if pt is not None else "" net_days = _t(order, "paymentTerms/netPayment/timePeriodDue") cab = { "emisor": _t(msg, "from/gln") if msg is not None else "", "receptor": _t(msg, "to/gln") if msg is not None else "", "numero_oc": substring_desde_fin(oc_num) if (oc_num is not None and len(oc_num) > 10) else "", "fecha_generacion": cd, "fecha_generacion_format": _format_datetime(cd), "fecha_embarque": _t(order, "movementDate"), "fecha_embarque_format": _format_date(_t(order, "movementDate")), "fecha_cancelacion": "", "fecha_cancelacion_format": "", "condiciones_pago": f"NET {net_days} días desde {_event_text(event)}" if net_days else "", "condiciones_pago_texto": f"NET {net_days} días desde {_event_text(event)}" if net_days else "", "lugar_entrega": _t(order, 'shipParty[@identificationType="SHIP_TO"]/gln'), "dpto_ventas": _t(order, "salesDepartamentNumber"), "tipo_oc": _t(order, "orderType"), "promocion": _t(order, "promotionDealNumber"), "numero_proveedor": _t(order, "internalVendorNumber"), "observaciones": _t(order, "specialHandling"), } carrier = _x(root, "//mainCarrierTransport") if carrier is not None: cab["etapa_transporte"] = _t(carrier, "mode") cab["transportista"] = _t(carrier, "carrierName") cab["divisa"] = _t(carrier, "../../currency") or _t(carrier, "../../../currency") or _t(carrier, "../currency") detalle = [] for item in _l(order, "lineItem"): np = int(_t(item, "netPrice/amount", "0")) rq = int(_t(item, "requestedQuantity", "0")) cu = int(_t(item, "containedUnits", "0")) ta = int(_t(item, "totalAmount/amount", "0")) currency = _a(item, "netPrice/amount", "currencyISOcode", "CLP") alw_list = [] for alw in _l(item, "allowanceCharge"): alw_list.append({ "type": alw.get("allowanceOrChargeType", ""), "percentage": int(_t(alw, "monetaryAmountOrPercentage/percentage", "0")), "amount": int(_t(alw, "monetaryAmountOrPercentage/amount", "0")), }) calc_total, applied_alw = apply_allowances(np, rq, alw_list) color = _t(item, "itemColor") size = _t(item, "itemSize") detalle.append({ "linea": item.get("number", ""), "cod_upc": _t(item, "itemIdentification/gtin"), "item": _t(item, "itemIdentification/buyerItemNumber"), "cod_prov": _t(item, "itemIdentification/vendorItemNumber"), "color_talla_um": "/ ".join(filter(None, [color, size])), "descripcion": _t(item, 'itemDescription[@language="SP"]/text'), "precio_original": np, "moneda": currency, "allowances": applied_alw, "precio_efectivo": round(calc_total / rq) if rq > 0 else 0, "empaques": rq, "unidad_empaque": _a(item, "requestedQuantity", "UnitType", ""), "unid_empaque": cu, "cantidad_unid": rq * cu, "importe": ta, }) return { "metadata": {"archivo": filename, "procesado": datetime.now().isoformat()}, "cabecera": cab, "detalle": detalle, "totales": { "importe_total": sum(d["importe"] for d in detalle), "moneda": detalle[0]["moneda"] if detalle else "CLP", }, } # ============================================================================ def write_json(data: dict, path: Path): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) # ============================================================================ def write_html(data: dict, template_path: Path, output_path: Path): env = Environment(loader=FileSystemLoader(str(template_path.parent))) template = env.get_template(template_path.name) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, "w", encoding="utf-8") as f: f.write(template.render(data=data)) # ============================================================================ def process_file(xml_path: Path): with log.task(f"📦 {xml_path.name}"): log.info("📄 XML parseado") with open(xml_path, "rb") as f: data = parse_order(f.read(), filename=xml_path.name) base = xml_path.stem write_json(data, OUTPUT_DIR / f"{base}.json") log.info("💾 JSON guardado") write_html(data, TEMPLATE_FILE, HTML_DIR / f"{base}.html") log.info("💾 HTML generado") if POST_TO_SAP_B1: with log.task("🔐 SAP B1"): try: from sapb1_mapper import to_draft_payload from sapb1_manager import create_orders from sql_manager import get_client_by_gln, get_item_catalog, get_price_list, get_prices, get_card_name from email_reporter import generate_email_html cab = data.get("cabecera", {}) emisor = cab.get("emisor", "") lugar_entrega = cab.get("lugar_entrega", "") result = get_client_by_gln(emisor, lugar_entrega) if result: cardcode = result["CardCode"] city = result["City"] county = result["County"] log.info("CardCode={} (desde CRD1)".format(cardcode)) else: cardcode = "" log.warning("No se obtuvo Socio de negocios para el {}.{}".format(emisor, lugar_entrega)) try: from mail_sender import send_mail send_mail( subject=f"OC {cab.get('numero_oc', '')} — Sin socio de negocios", text=f"No se obtuvo Socio de negocios para el {emisor}.{lugar_entrega}", ) log.info("Correo de notificación enviado") except Exception as e: log.warning("Error enviando correo de notificación: {}".format(e)) ERRORS_DIR.mkdir(parents=True, exist_ok=True) shutil.move(str(xml_path), str(ERRORS_DIR / xml_path.name)) log.info("📁 XML movido a errors/") return item_catalog = get_item_catalog(cardcode) pricelist = get_price_list(cardcode) prices = get_prices(pricelist) if pricelist else {} log.info("Catálogo: {} items, precios: {} registros".format(len(item_catalog), len(prices))) cardname = get_card_name(cardcode) email_html = generate_email_html(data, cardcode, cardname, item_catalog, prices) (HTML_DIR / f"{base}_email.html").write_text(email_html, encoding="utf-8") log.info("Email HTML generado") payload = to_draft_payload(data, cardcode=cardcode, city=city, county=county, item_catalog=item_catalog, prices=prices) write_json(payload, SAP_B1_DIR / f"{base}_sap.json") log.info("Payload guardado en data/sap_b1/") det = data.get("detalle", []) price_ok = True for d in det: upc = d.get("cod_upc", "") or "" sap_item = item_catalog.get(upc, "") sap_price = prices.get(sap_item) if sap_item else None importe = d.get("importe", 0) or 0 qty = d.get("cantidad_unid", 1) or 1 precio_xml = round(importe / qty) if qty > 0 else 0 if not sap_item or sap_price is None or sap_price != precio_xml: price_ok = False log.warning("⚠️ {}: difiere (XML={} vs SAP={})".format(upc, precio_xml, sap_price)) if not price_ok: email_path = HTML_DIR / f"{base}_email.html" num_oc = cab.get("numero_oc", "") or base try: from mail_sender import send_validation_email send_validation_email( email_path, subject=f"Validación OC {num_oc} — diferencias precio/catálogo", ) log.info("Notificación enviada a MAIL_TO") except Exception as e: log.warning("Error enviando correo: {}".format(e)) create_orders(payload) except ImportError as e: log.warning("⚠️ SAP saltado (dependencia faltante: {})".format(e)) except Exception as e: log.warning("❌ SAP error: {}".format(e)) PROCESSED_DIR.mkdir(parents=True, exist_ok=True) shutil.move(str(xml_path), str(PROCESSED_DIR / xml_path.name)) log.info("📁 XML movido a processed/") # ============================================================================ def process_all(): INPUT_DIR.mkdir(parents=True, exist_ok=True) files = sorted(f for f in INPUT_DIR.iterdir() if f.is_file()) if not files: return log.info("📦 Archivos pendientes: {}".format(len(files))) for f in files: try: process_file(f) except Exception as e: log.error("❌ Error con {}: {}".format(f.name, e)) # ============================================================================