chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-21 18:26:30 +02:00
commit 969c868217
41 changed files with 10149 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
#!/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()