232 lines
9.1 KiB
Python
232 lines
9.1 KiB
Python
#!/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()
|