#!/usr/bin/env python3 """Create Metabase document with analysis results.""" import json import os import subprocess import sys from pathlib import Path import httpx API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0] BASE = "https://reports.autingo.es" HERE = Path(__file__).parent RES = HERE / "data" / "results" COLLECTION_ID = 559 # "Datos de call center" client = httpx.Client(base_url=BASE, headers={"x-api-key": API_KEY}, timeout=120) # ---- load results ---- totales = json.loads((RES / "totales_globales.json").read_text()) conv = json.loads((RES / "01_conversion_origen.json").read_text()) kpi = json.loads((RES / "02_kpi_3_por_centro.json").read_text()) regen = json.loads((RES / "03_regen_por_centro.json").read_text()) rvc = json.loads((RES / "04_regen_vs_conversion.json").read_text()) # ---- prosemirror builders ---- ALLOWED = {"doc","heading","paragraph","text","horizontalRule","blockquote", "bulletList","listItem","codeBlock"} def h(level, text): return {"type": "heading", "attrs": {"level": level}, "content": [{"type": "text", "text": text}]} def p(*parts): content = [] for x in parts: if isinstance(x, str): content.append({"type": "text", "text": x}) else: content.append(x) return {"type": "paragraph", "content": content} def bold(text): return {"type": "text", "text": text, "marks": [{"type": "bold"}]} def code(text): return {"type": "text", "text": text, "marks": [{"type": "code"}]} def bullet_list(items): nodes = [] for it in items: if isinstance(it, str): paras = [p(it)] elif isinstance(it, list): paras = it else: paras = [it] nodes.append({"type": "listItem", "content": paras}) return {"type": "bulletList", "content": nodes} def hr(): return {"type": "horizontalRule"} def code_block(text, lang="sql"): return {"type": "codeBlock", "attrs": {"language": lang}, "content": [{"type": "text", "text": text}]} def fmt_eur(n): return f"{n:,.0f} €".replace(",", "X").replace(".", ",").replace("X", ".") def fmt_pct(n): return f"{n*100:.2f}%".replace(".", ",") # ---- build content ---- # KPI table emulated as bullet list (table not allowed) kpi_rows = kpi["rows"] top10_a = sorted(kpi_rows, key=lambda r: r[3], reverse=True)[:10] top10_c = sorted(kpi_rows, key=lambda r: r[5], reverse=True)[:10] regen_rows = regen["rows"] top10_regen = regen_rows[:10] conv_rows = conv["rows"] conv_cc = next(r for r in conv_rows if r[0] == "call_center") conv_otro = next(r for r in conv_rows if r[0] == "otro") doc_content = [ h(1, "Presupuestos generados por Call Center → Facturación"), p("Ventana: últimos 90 días. Datos: ", code("psql_dcpublic"), " en BigQuery ", code("autingo-159109"), ". Centros call_center excluidos del cómputo de facturación física: ", code("159 CALL CENTER AURGI"), " y ", code("162 CALL CENTER"), "."), h(2, "Metodología"), p("Identificación del origen call_center vía ", code("tpv_authorization_tpvuser_centers.dccenter_id IN (159, 162)"), ". El ", code("tpv_orders_quote.created_by_id"), " se cruza con ese conjunto de usuarios (249 usuarios call_center)."), p("Cadena de joins:"), code_block( "tpv_authorization_tpvuser_centers (dccenter_id ∈ {159, 162})\n" " └─ tpvuser_id ─► tpv_orders_quote.created_by_id\n" " └─ order_id ─► tpv_orders_order ─► terminal_id ─► tpv_terminals.center_id ─► centers\n" " └─ order_id ─► tpv_orders_invoice (convertido si fila existe)\n" " └─ customer_id, vehicle_id (identidad cliente)", "text"), p("Identidad del cliente = ", code("(customer_id, vehicle_id)"), " del order. " "Conversión a factura = existe fila en ", code("tpv_orders_invoice"), " con el mismo ", code("order_id"), "."), hr(), h(2, "Totales globales (90 días)"), bullet_list([ [p(bold("A — € quotes call_center facturados (mismo order_id): "), fmt_eur(totales["A_quote_cc_eur"]))], [p(bold("B — € facturados a esos mismos clientes en centros físicos: "), fmt_eur(totales["B_mismo_cliente_eur"]), " (lift ", f"{totales['lift_B_vs_A']:.2f}×".replace(".", ","), " sobre A)")], [p(bold("C — € totales facturados en centros físicos: "), fmt_eur(totales["C_total_centros_eur"]))], [p(bold("A / C = "), fmt_pct(totales["A_sobre_C"]), " — peso directo del call_center en facturación de centros")], [p(bold("B / C = "), fmt_pct(totales["B_sobre_C"]), " — peso de clientes tocados por call_center")], [p(bold("Centros activos en ventana: "), str(totales["centros_activos"]))], ]), p("Lectura: el call_center trae directamente ~21,6% de la facturación de centros, " "pero sus clientes acaban facturando ~23,4% (1,08× más): el centro suele añadir " "valor al ticket cuando el cliente acude. Brecha B−A = ", fmt_eur(totales["B_mismo_cliente_eur"] - totales["A_quote_cc_eur"]), "."), hr(), h(2, "Conversión de quote → factura por origen"), bullet_list([ [p(bold(f"Call center: "), f"{conv_cc[1]:,} quotes / 90d → {conv_cc[2]:,} facturas → ", bold(fmt_pct(conv_cc[3])))], [p(bold(f"Otros usuarios: "), f"{conv_otro[1]:,} quotes / 90d → {conv_otro[2]:,} facturas → ", bold(fmt_pct(conv_otro[3])))], ]), p("Brecha esperable de ~10 pp: el quote del centro se hace casi siempre con el " "cliente delante; el del call_center es 'en frío' (vía teléfono)."), hr(), h(2, "Top 10 centros por A (más facturado vía quote call_center)"), bullet_list([ [p(bold(r[1]), " — A=", fmt_eur(r[3]), " · B=", fmt_eur(r[4]), " · C=", fmt_eur(r[5]), " · A/C=", fmt_pct(r[6]), " · lift B/A=", str(r[8]) if r[8] is not None else "—", "×")] for r in top10_a ]), h(2, "Top 10 centros por C (facturación total)"), bullet_list([ [p(bold(r[1]), " — A=", fmt_eur(r[3]), " · B=", fmt_eur(r[4]), " · C=", fmt_eur(r[5]), " · A/C=", fmt_pct(r[6]), " · B/C=", fmt_pct(r[7]))] for r in top10_c ]), hr(), h(2, "Regeneración del presupuesto"), p("Regeneración = un mismo par ", code("(customer_id, vehicle_id)"), " con un Q0 abierto por call_center y un Q1 posterior abierto en un terminal de " "centro físico no-call_center con ", code("order_id"), " distinto, dentro de los 60 días siguientes."), p(bold("Volumen Q0 con / sin regeneración:")), bullet_list([ [p(bold("No regenerado: "), f"{rvc['rows'][0][1]:,} Q0 — convierte ", bold(fmt_pct(rvc['rows'][0][3])), " sobre su order_id original")], [p(bold("Regenerado: "), f"{rvc['rows'][1][1]:,} Q0 — convierte ", bold(fmt_pct(rvc['rows'][1][3])), " sobre su order_id original")], ]), p("Es decir, ~34,2% de los Q0 del call_center entran en patrón de regeneración. " "La conversión sobre el order_id original cae del 63,1% al 38,7% en ese segmento, " "pero el cliente puede haber facturado por el order_id del centro — esa parte queda " "capturada por el KPI B."), h(2, "Top 10 centros que MÁS regeneran (90d / window 60d)"), bullet_list([ [p(bold(r[1]), " — Q0 regenerados aquí: ", str(r[2]), " · eventos: ", str(r[3]), " · días promedio entre Q0 y regeneración: ", f"{r[4]:.1f}".replace(".", ","))] for r in top10_regen ]), hr(), h(2, "Cuestiones abiertas"), bullet_list([ "Filtrar invoices por status válido (excluir rectificativas/anuladas)", "¿Sumar líneas (orderitem.total_price) en vez de order.total_cost? Captura descuentos finales mejor", "Refinar identidad cliente con teléfono normalizado (BI views *_telefono_normalizado)", "Validar si algún centro fuera de 159/162 actúa como call_center mixto", ]), h(2, "Origen del análisis"), p("Notebooks ejecutados: ", code("projects/aurgi/analysis/presupuestos_callcenter/notebooks/"), " en el repo ", code("fn_registry"), ". Datos crudos: ", code("data/results/*.csv"), "."), ] doc = {"type": "doc", "content": doc_content} # ---- validate before POST ---- def validate(node, path=""): errs = [] if isinstance(node, dict): typ = node.get("type", "?") if typ not in ALLOWED: errs.append(f"{path}: tipo no permitido '{typ}'") for i, c in enumerate(node.get("content", []) or []): errs += validate(c, f"{path}/{typ}[{i}]") return errs errs = validate(doc) if errs: print("INVALID DOC:") for e in errs: print(" ", e) sys.exit(1) print("doc valid, posting...") # ---- POST ---- r = client.post("/api/document", json={ "name": "Call Center — Presupuestos → Facturación (90d)", "collection_id": COLLECTION_ID, "document": doc, }) if r.status_code >= 400: print(r.status_code, r.text) sys.exit(1) data = r.json() print(f"created document id={data['id']}") print(f"url: {BASE}/document/{data['id']}") client.close()