Files
presupuestos_callcenter/create_doc.py
T
2026-05-21 18:26:30 +02:00

232 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 BA = ",
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()