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
+40
View File
@@ -0,0 +1,40 @@
# JUPYTER HABILITADO EN ESTE ANALISIS
## Reglas OBLIGATORIAS para Claude
### 1. CODIGO INMUTABLE — NUNCA MODIFICAR CELDAS EXISTENTES
- **PROHIBIDO** usar NotebookEdit para reemplazar celdas existentes
- **SIEMPRE** anadir celdas NUEVAS al final del notebook
- Si hay un error en una celda, crear celda nueva con la correccion
- El historial de trabajo debe quedar intacto para trazabilidad
### 2. PROGRAMACION FUNCIONAL OBLIGATORIA
- **Funciones puras**: sin efectos secundarios, mismo input -> mismo output
- **Inmutabilidad**: nunca mutar datos, crear copias transformadas
- **Composicion**: funciones pequenas que se combinan
- Preferir: `map`, `filter`, `reduce`, list comprehensions
- Evitar: loops con mutacion, `global`, modificar argumentos in-place
### 3. SIEMPRE usar MCP jupyter para ejecutar codigo Python
- Las ejecuciones se ven en tiempo real en Jupyter Lab del usuario
- Compartimos variables y estado del kernel
- **NUNCA usar bash para ejecutar Python en este analisis**
### 4. Verificar Jupyter activo ANTES de ejecutar
- Si no esta activo: pedir al usuario que ejecute `./run-jupyter-lab.sh`
### 5. Gestion de notebooks
- Notebooks en la carpeta `notebooks/` o subcarpetas
- Si un notebook tiene >50 celdas, crear uno nuevo
- Nombrar descriptivamente: `01_exploracion.ipynb`, `02_limpieza.ipynb`
### 6. Gestion de Python
- **SIEMPRE usar `uv`** para gestionar dependencias
- Anadir paquetes con `uv add nombre_paquete`
### 7. Acceso al fn_registry
- `FN_REGISTRY_ROOT` apunta a la raiz del registry
- Para importar funciones Python: `sys.path.insert(0, os.path.join(os.environ["FN_REGISTRY_ROOT"], "python", "functions"))`
- Para consultar registry.db: `sqlite3` o `import sqlite3` con la ruta `$FN_REGISTRY_ROOT/registry.db`
Binary file not shown.
@@ -0,0 +1,100 @@
"""
fn_registry kernel startup
Autoconfigura acceso al registry en cada notebook.
Generado por write_jupyter_registry_kernel (fn_registry).
"""
import os
import sys
import sqlite3
from pathlib import Path
# ── FN_REGISTRY_ROOT ────────────────────────────────────────
# Prioridad: env var > path hardcoded > descubrimiento automatico
def _discover_registry_root():
if os.environ.get("FN_REGISTRY_ROOT"):
return Path(os.environ["FN_REGISTRY_ROOT"]).resolve()
hardcoded = Path("/home/egutierrez/fn_registry")
if (hardcoded / "registry.db").exists():
return hardcoded
# Subir desde CWD hasta encontrar registry.db
p = Path.cwd()
for _ in range(10):
if (p / "registry.db").exists():
return p
if p.parent == p:
break
p = p.parent
return hardcoded
FN_REGISTRY_ROOT = _discover_registry_root()
os.environ["FN_REGISTRY_ROOT"] = str(FN_REGISTRY_ROOT)
# ── sys.path: importar funciones Python del registry ────────
_python_functions = FN_REGISTRY_ROOT / "python" / "functions"
for _domain in sorted(_python_functions.iterdir()) if _python_functions.exists() else []:
if _domain.is_dir() and not _domain.name.startswith("_"):
_path = str(_domain)
if _path not in sys.path:
sys.path.insert(0, _path)
# Tambien el directorio padre para imports por dominio: from core import filter_list
_pf = str(_python_functions)
if _pf not in sys.path:
sys.path.insert(0, _pf)
# ── fn_query: consultar registry.db desde el notebook ───────
_REGISTRY_DB = FN_REGISTRY_ROOT / "registry.db"
def fn_query(sql, params=()):
"""Ejecuta una consulta SQL sobre registry.db y retorna las filas.
Ejemplos:
fn_query("SELECT id, description FROM functions WHERE domain = ?", ("finance",))
fn_query("SELECT id FROM functions_fts WHERE functions_fts MATCH ?", ("slice*",))
"""
if not _REGISTRY_DB.exists():
raise FileNotFoundError(f"registry.db no encontrado en {_REGISTRY_DB}")
con = sqlite3.connect(str(_REGISTRY_DB))
con.row_factory = sqlite3.Row
try:
rows = con.execute(sql, params).fetchall()
return [dict(r) for r in rows]
finally:
con.close()
def fn_search(term):
"""Busca funciones y tipos en el registry por nombre o descripcion.
Ejemplo:
fn_search("slice")
fn_search("finance")
"""
fts_term = f"name:{term}* OR description:{term}*"
functions = fn_query(
"SELECT id, kind, purity, lang, description FROM functions "
"WHERE id IN (SELECT id FROM functions_fts WHERE functions_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
types = fn_query(
"SELECT id, algebraic, lang, description FROM types "
"WHERE id IN (SELECT id FROM types_fts WHERE types_fts MATCH ?) "
"ORDER BY name", (fts_term,)
)
return {"functions": functions, "types": types}
def fn_code(function_id):
"""Retorna el codigo fuente de una funcion del registry.
Ejemplo:
print(fn_code("filter_list_py_core"))
"""
rows = fn_query("SELECT code FROM functions WHERE id = ?", (function_id,))
if not rows:
raise KeyError(f"Funcion no encontrada: {function_id}")
return rows[0]["code"]
# ── Mensaje de bienvenida ───────────────────────────────────
print(f"fn_registry conectado: {FN_REGISTRY_ROOT}")
print(f" registry.db: {'OK' if _REGISTRY_DB.exists() else 'NO ENCONTRADO'}")
print(f" Python functions: {_pf}")
print(f" Helpers: fn_query(), fn_search(), fn_code()")
+1
View File
@@ -0,0 +1 @@
8888
+7
View File
@@ -0,0 +1,7 @@
{
"577c4238-54a0-43dd-9392-9c50040ee0d2": {
"version": "2.4.0",
"created_at": "2026-05-21T10:39:30.038590+00:00",
"document_version": "2.0.0"
}
}
Binary file not shown.
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"jupyter": {
"command": "/home/egutierrez/fn_registry/projects/aurgi/analysis/presupuestos_callcenter/.venv/bin/python",
"args": ["-m", "jupyter_mcp_server.server"],
"env": {
"SERVER_URL": "http://localhost:8888",
"TOKEN": ""
}
}
}
}
+1
View File
@@ -0,0 +1 @@
3.13
+53
View File
@@ -0,0 +1,53 @@
# presupuestos_callcenter
Atribución de presupuestos generados por call_center → factura.
## Hipótesis
1. Agentes del call_center generan presupuestos (`tpv_orders_quote`) para un cliente identificado por `(customer_id, vehicle_id)` (matrícula + tlf vía joins).
2. El cliente luego acude a un centro físico. Allí:
- **Acepta el quote del call_center** → mismo `order_id` → invoice. CONVIERTE.
- **Genera quote nuevo en TPV centro** → otro `order_id` → invoice. REGENERA y convierte.
- **No vuelve** → quote sin invoice.
3. La facturación total a esos clientes en el centro suele ser mayor que el valor del quote inicial (productos/servicios adicionales que el técnico añade en recepción).
## Datasets
| Origen | Tabla / propósito |
|---|---|
| `psql_dcpublic.tpv_orders_quote` | Quote raw — `created_by_id`, `order_id`, `accepted`, `status` |
| `psql_dcpublic.tpv_authorization_tpvuser` | Users TPV. ID = `created_by_id` |
| `psql_dcpublic.tpv_authorization_tpvuser_centers` | Mapeo user ↔ centro (`dccenter_id`) |
| `psql_dcpublic.centers` | Catálogo centros. **id 159 (CALL CENTER AURGI) e id 162 (CALL CENTER)** son los centros call_center |
| `psql_dcpublic.tpv_orders_order` | Order. `customer_id`, `vehicle_id`, `terminal_id`, `total_cost` |
| `psql_dcpublic.tpv_terminals` | Mapeo `terminal_id → center_id` |
| `psql_dcpublic.tpv_orders_invoice` | Invoice. Match con order via `order_id` |
| `psql_dcpublic.tpv_customers.entity_phone_number` | Tlf cliente |
| `psql_dcpublic.tpv_vehicles_vehicle.license_plate` | Matrícula |
## Notebooks
1. **`01_exploracion.ipynb`** — schemas, sanity counts, conversion rate global.
2. **`02_metricas_3kpi.ipynb`** — A/B/C por centro:
- A = € facturados de quotes call_center que CONVIRTIERON (mismo order_id).
- B = € facturados al mismo `(customer_id, vehicle_id)` en centro (excluye call_center).
- C = € totales del centro.
3. **`03_regeneracion.ipynb`** — Q0 (call_center) vs Q1+ (centro). Centros que más regeneran, distribución temporal, impacto en conversión.
## Identidad cliente
Por defecto `(customer_id, vehicle_id)` (FK estables). Si necesitas normalizar via tlf+matrícula:
```sql
JOIN `psql_dcpublic.tpv_customers` cus ON o.customer_id = cus.id
JOIN `psql_dcpublic.tpv_vehicles_vehicle` v ON o.vehicle_id = v.id
-- cus.entity_phone_number, v.license_plate
```
Ventana temporal default: 90 días sobre Q0, 60 días para detectar regeneración posterior. Editar `WINDOW_DAYS` / `REGEN_WINDOW_DAYS` arriba en cada notebook.
## Próximos pasos
- Validar que `created_by_id` no es null en todos los quotes call_center.
- Refinar identidad cliente con normalización de teléfono (la BI views `*_telefono_normalizado` existen).
- Si la métrica B es estable, candidatos a dashboard Metabase paralelo al 999.
+17
View File
@@ -0,0 +1,17 @@
---
name: presupuestos_callcenter
lang: py
domain: datascience
description: "Atribucion de presupuestos call_center -> factura: total convertido, valor mismo cliente en centro, facturacion total centro, regeneracion de quotes por centro"
tags: [aurgi, call_center, quotes, bigquery]
uses_functions: []
uses_types: []
framework: "jupyterlab"
entry_point: "notebooks/main.ipynb"
dir_path: "projects/aurgi/analysis/presupuestos_callcenter"
repo_url: ""
---
## Notas
Atribucion de presupuestos call_center -> factura: total convertido, valor mismo cliente en centro, facturacion total centro, regeneracion de quotes por centro
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Build Metabase dashboard 'Presupuestos Call Center → Facturación'.
5 dashboard filters: fecha (date/range), centro, agente CC, compañía, producto.
3 KPIs × 3 cards each = 9 scalar cards:
A — Presupuestos Call center (quote CC → invoice mismo order_id)
B — Presupuestos CallC regenerados (Q0 CC → Q1 en centro físico → invoice)
C — Total facturado (invoice en centros, excluye 159/162)
Métricas por KPI:
- Total facturado (€)
- # Facturas
- Ticket medio (€) = Total / #
"""
import json
import os
import subprocess
import sys
import httpx
API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0]
BASE = "https://reports.autingo.es"
DB_ID = 6
COLLECTION_ID = 559 # "Datos de call center"
# Field IDs (precomputados via /api/table/<id>/query_metadata)
F_INVOICE_CREATED_AT = 16235
F_CENTER_ID = 17327
F_TPVUSER_ID = 17965
F_COMPANY_ID = 17157
F_PRODUCT_ID = 16698
client = httpx.Client(base_url=BASE, headers={"x-api-key": API_KEY}, timeout=120)
# ---------- Template tags ----------
def field_filter_tag(name: str, field_id: int, widget: str, display_name: str):
return {
"id": name + "-tag",
"name": name,
"display-name": display_name,
"type": "dimension",
"dimension": ["field", field_id, None],
"widget-type": widget,
"default": None,
}
# Tag set para A y B (5 filtros)
TAGS_AB = {
"date": field_filter_tag("date", F_INVOICE_CREATED_AT, "date/range", "Fecha"),
"centro": field_filter_tag("centro", F_CENTER_ID, "id", "Centro"),
"agente": field_filter_tag("agente", F_TPVUSER_ID, "id", "Agente CC"),
"compania": field_filter_tag("compania", F_COMPANY_ID, "id", "Compañía"),
"producto": field_filter_tag("producto", F_PRODUCT_ID, "id", "Producto"),
}
# Tag set para C (4 filtros — sin agente)
TAGS_C = {k: v for k, v in TAGS_AB.items() if k != "agente"}
# ---------- SQL skeletons ----------
SQL_A = """
WITH
cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `psql_dcpublic.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM `psql_dcpublic.tpv_orders_quote`
JOIN cc_users ON `psql_dcpublic.tpv_orders_quote`.created_by_id = cc_users.user_id
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_authorization_tpvuser` ON `psql_dcpublic.tpv_orders_quote`.created_by_id = `psql_dcpublic.tpv_authorization_tpvuser`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
AND COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{date}}]]
[[AND {{centro}}]]
[[AND {{agente}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
SQL_B = """
WITH
cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `psql_dcpublic.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
q0 AS (
SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,
q.created_by_id AS cc_agent_id,
o.customer_id, o.vehicle_id
FROM `psql_dcpublic.tpv_orders_quote` q
JOIN cc_users cc ON q.created_by_id = cc.user_id
JOIN `psql_dcpublic.tpv_orders_order` o ON q.order_id = o.id
WHERE q.deleted_at IS NULL
AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL
),
qN AS (
SELECT q.id AS qn_id, q.order_id AS qn_order, q.created_at AS qn_ts,
o.customer_id, o.vehicle_id, t.center_id
FROM `psql_dcpublic.tpv_orders_quote` q
JOIN `psql_dcpublic.tpv_orders_order` o ON q.order_id = o.id
LEFT JOIN `psql_dcpublic.tpv_terminals` t ON o.terminal_id = t.id
WHERE q.deleted_at IS NULL
AND t.center_id IS NOT NULL AND t.center_id NOT IN (159, 162)
),
pairs AS (
SELECT DISTINCT q0.cc_agent_id, qN.qn_order
FROM q0
JOIN qN
ON q0.customer_id = qN.customer_id
AND q0.vehicle_id = qN.vehicle_id
AND qN.qn_ts > q0.q0_ts
AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL 60 DAY)
AND qN.qn_order != q0.q0_order
),
filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM pairs
JOIN `psql_dcpublic.tpv_orders_order` ON pairs.qn_order = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_authorization_tpvuser` ON pairs.cc_agent_id = `psql_dcpublic.tpv_authorization_tpvuser`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
WHERE 1=1
[[AND {{date}}]]
[[AND {{centro}}]]
[[AND {{agente}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
SQL_C = """
WITH filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM `psql_dcpublic.tpv_orders_invoice`
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
WHERE COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{date}}]]
[[AND {{centro}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
AGG = {
"total": "ROUND(SUM(total_cost), 2)",
"count": "COUNT(*)",
"ticket": "ROUND(SAFE_DIVIDE(SUM(total_cost), NULLIF(COUNT(*), 0)), 2)",
}
def make_card(name: str, sql_skel: str, agg_key: str, tags: dict, display: str, currency: bool):
sql = sql_skel.replace("__AGG__", AGG[agg_key])
viz = {}
if currency:
viz = {
"column_settings": {
'["name","valor"]': {
"number_style": "currency",
"currency": "EUR",
"currency_style": "symbol",
"currency_in_header": False,
"decimals": 0,
}
}
}
body = {
"name": name,
"type": "question",
"display": display,
"visualization_settings": viz,
"dataset_query": {
"type": "native",
"database": DB_ID,
"native": {"query": sql, "template-tags": tags},
},
"collection_id": COLLECTION_ID,
"description": name,
"result_metadata": None,
}
r = client.post("/api/card", json=body)
if r.status_code >= 400:
print(r.status_code, r.text[:500])
r.raise_for_status()
return r.json()
# ---------- Build 9 cards ----------
specs = [
# (id, name, skeleton, agg, tags, display, currency)
("a_total", "A · Presupuestos Call center — Total facturado", SQL_A, "total", TAGS_AB, "scalar", True),
("a_count", "A · Presupuestos Call center — # Facturas", SQL_A, "count", TAGS_AB, "scalar", False),
("a_ticket", "A · Presupuestos Call center — Ticket medio", SQL_A, "ticket", TAGS_AB, "scalar", True),
("b_total", "B · Presupuestos CallC regenerados — Total facturado", SQL_B, "total", TAGS_AB, "scalar", True),
("b_count", "B · Presupuestos CallC regenerados — # Facturas", SQL_B, "count", TAGS_AB, "scalar", False),
("b_ticket", "B · Presupuestos CallC regenerados — Ticket medio", SQL_B, "ticket", TAGS_AB, "scalar", True),
("c_total", "C · Total facturado — Total facturado", SQL_C, "total", TAGS_C, "scalar", True),
("c_count", "C · Total facturado — # Facturas", SQL_C, "count", TAGS_C, "scalar", False),
("c_ticket", "C · Total facturado — Ticket medio", SQL_C, "ticket", TAGS_C, "scalar", True),
]
cards = {}
for sid, name, skel, agg, tags, display, currency in specs:
print(f"creating {sid} ... {name}")
c = make_card(name, skel, agg, tags, display, currency)
cards[sid] = c
print(f" -> id={c['id']}")
# ---------- Build dashboard ----------
print("\ncreating dashboard")
dash = client.post("/api/dashboard", json={
"name": "Call Center — Presupuestos · KPIs (90d)",
"collection_id": COLLECTION_ID,
"description": "A=Quotes CC facturados · B=CallC regenerados · C=Total facturado · Filtros: fecha/centro/agente/compañía/producto",
}).json()
DID = dash["id"]
print(f" dashboard id={DID}")
# Dashboard-level parameters
DPARAMS = [
{"id": "p_date", "name": "Fecha", "slug": "fecha", "type": "date/range", "sectionId": "date"},
{"id": "p_centro", "name": "Centro", "slug": "centro", "type": "id", "sectionId": "id"},
{"id": "p_agente", "name": "Agente CC", "slug": "agente", "type": "id", "sectionId": "id"},
{"id": "p_compania", "name": "Compañía", "slug": "compania", "type": "id", "sectionId": "id"},
{"id": "p_producto", "name": "Producto", "slug": "producto", "type": "id", "sectionId": "id"},
]
# Parameter mappings: each dashboard param → each card's template-tag target
def mapping_for_card(card_id: int, tag_keys: list[str]):
return [
{
"parameter_id": f"p_{k}",
"card_id": card_id,
"target": ["dimension", ["template-tag", k]],
}
for k in tag_keys
]
# Grid: 24 wide. Columns 0/8/16 = 8 wide. Rows 0/4/8 by KPI band.
DASHCARDS = []
layout = [
# (sid, row, col, h)
("a_total", 0, 0, 4), ("a_count", 0, 8, 4), ("a_ticket", 0, 16, 4),
("b_total", 4, 0, 4), ("b_count", 4, 8, 4), ("b_ticket", 4, 16, 4),
("c_total", 8, 0, 4), ("c_count", 8, 8, 4), ("c_ticket", 8, 16, 4),
]
neg = -1
for sid, row, col, h in layout:
card = cards[sid]
tag_keys = list((TAGS_AB if sid[0] in "ab" else TAGS_C).keys())
DASHCARDS.append({
"id": neg,
"card_id": card["id"],
"row": row,
"col": col,
"size_x": 8,
"size_y": h,
"parameter_mappings": mapping_for_card(card["id"], tag_keys),
"visualization_settings": {},
})
neg -= 1
# Update dashboard with params + dashcards
r = client.put(f"/api/dashboard/{DID}", json={
"parameters": DPARAMS,
"dashcards": DASHCARDS,
})
if r.status_code >= 400:
print(r.status_code, r.text[:800])
r.raise_for_status()
print(f"\nDONE")
print(f"dashboard URL: {BASE}/dashboard/{DID}")
client.close()
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Dashboard v2 — coherencia:
- Date filter en `tpv_orders_quote.created_at` (creacion de presupuesto) para A B C.
- A = quotes CC -> factura (mismo order_id).
- B = (A) (quotes regenerados en centro fisico desde Q0 CC dentro 60d) -> facturas.
- C = invoices de centros fisicos cuyo order tiene AL MENOS un quote en la ventana
(98.7% de invoices tienen quote, solo cae 3.7% del valor — aceptable).
"""
import subprocess
import httpx
API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0]
BASE = "https://reports.autingo.es"
DB_ID = 6
COLLECTION_ID = 559
F_QUOTE_CREATED_AT = 16588
F_CENTER_ID = 17327
F_TPVUSER_ID = 17965
F_COMPANY_ID = 17157
F_PRODUCT_ID = 16698
client = httpx.Client(base_url=BASE, headers={"x-api-key": API_KEY}, timeout=180)
def field_filter_tag(name: str, field_id: int, widget: str, display_name: str):
return {
"id": name + "-tag",
"name": name,
"display-name": display_name,
"type": "dimension",
"dimension": ["field", field_id, None],
"widget-type": widget,
"default": None,
}
TAGS_AB = {
"date": field_filter_tag("date", F_QUOTE_CREATED_AT, "date/range", "Fecha presupuesto"),
"centro": field_filter_tag("centro", F_CENTER_ID, "id", "Centro"),
"agente": field_filter_tag("agente", F_TPVUSER_ID, "id", "Agente CC"),
"compania": field_filter_tag("compania", F_COMPANY_ID, "id", "Compañía"),
"producto": field_filter_tag("producto", F_PRODUCT_ID, "id", "Producto"),
}
TAGS_C = {k: v for k, v in TAGS_AB.items() if k != "agente"}
# ---- SQL ----
# Convention: tabla cuya columna se filtra via field-filter NO lleva alias.
# Demas tablas pueden usar alias o full ref.
SQL_A = """
WITH cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `psql_dcpublic.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM `psql_dcpublic.tpv_orders_quote`
JOIN cc_users ON `psql_dcpublic.tpv_orders_quote`.created_by_id = cc_users.user_id
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_authorization_tpvuser` ON `psql_dcpublic.tpv_orders_quote`.created_by_id = `psql_dcpublic.tpv_authorization_tpvuser`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
AND COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{date}}]]
[[AND {{centro}}]]
[[AND {{agente}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
# B: union de Q-CC directos + Q1 regenerados (desde Q0-CC dentro 60d).
# Q1 vive en una CTE clon de la tabla quote para no chocar con field-filter de Q-CC.
SQL_B = """
WITH cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `psql_dcpublic.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
quotes_q1 AS (
SELECT id, order_id, created_at, deleted_at
FROM `psql_dcpublic.tpv_orders_quote`
),
orders_q1 AS (
SELECT id, customer_id, vehicle_id, terminal_id
FROM `psql_dcpublic.tpv_orders_order`
),
terminals_q1 AS (
SELECT id, center_id FROM `psql_dcpublic.tpv_terminals`
),
-- Q0 (CC) anchor; field-filters (date/agente) aplican sobre la quote CC
cc_anchored AS (
SELECT
`psql_dcpublic.tpv_orders_quote`.id AS q0_id,
`psql_dcpublic.tpv_orders_quote`.order_id AS q0_order,
`psql_dcpublic.tpv_orders_quote`.created_at AS q0_ts,
`psql_dcpublic.tpv_orders_quote`.created_by_id AS cc_agent_id,
`psql_dcpublic.tpv_orders_order`.customer_id AS cust_id,
`psql_dcpublic.tpv_orders_order`.vehicle_id AS veh_id
FROM `psql_dcpublic.tpv_orders_quote`
JOIN cc_users ON `psql_dcpublic.tpv_orders_quote`.created_by_id = cc_users.user_id
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_authorization_tpvuser` ON `psql_dcpublic.tpv_orders_quote`.created_by_id = `psql_dcpublic.tpv_authorization_tpvuser`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
[[AND {{date}}]]
[[AND {{agente}}]]
),
-- Orders incluidos: Q-CC directos + Q1 regenerados
b_orders AS (
SELECT q0_order AS order_id FROM cc_anchored
UNION DISTINCT
SELECT q1.order_id AS order_id
FROM cc_anchored a
JOIN quotes_q1 q1
ON q1.deleted_at IS NULL
AND q1.created_at > a.q0_ts
AND q1.created_at <= TIMESTAMP_ADD(a.q0_ts, INTERVAL 60 DAY)
AND q1.order_id != a.q0_order
JOIN orders_q1 o1 ON q1.order_id = o1.id
LEFT JOIN terminals_q1 t1 ON o1.terminal_id = t1.id
WHERE o1.customer_id = a.cust_id
AND o1.vehicle_id = a.veh_id
AND t1.center_id IS NOT NULL
AND t1.center_id NOT IN (159, 162)
),
filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM b_orders
JOIN `psql_dcpublic.tpv_orders_order` ON b_orders.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
WHERE COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{centro}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
# C: total facturado en centros fisicos cuyos orders tienen al menos un quote en la ventana
SQL_C = """
WITH filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM `psql_dcpublic.tpv_orders_quote`
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
AND COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{date}}]]
[[AND {{centro}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
AGG = {
"total": "ROUND(SUM(total_cost), 2)",
"count": "COUNT(*)",
"ticket": "ROUND(SAFE_DIVIDE(SUM(total_cost), NULLIF(COUNT(*), 0)), 2)",
}
def make_card(name, sql_skel, agg_key, tags, display, currency):
sql = sql_skel.replace("__AGG__", AGG[agg_key])
viz = {}
if currency:
viz = {"column_settings": {'["name","valor"]': {
"number_style": "currency", "currency": "EUR",
"currency_style": "symbol", "currency_in_header": False, "decimals": 0,
}}}
body = {
"name": name,
"type": "question",
"display": display,
"visualization_settings": viz,
"dataset_query": {
"type": "native", "database": DB_ID,
"native": {"query": sql, "template-tags": tags},
},
"collection_id": COLLECTION_ID,
"description": name,
"result_metadata": None,
}
r = client.post("/api/card", json=body)
if r.status_code >= 400:
print(r.status_code, r.text[:500]); r.raise_for_status()
return r.json()
specs = [
("a_total", "A · Presupuestos Call center — Total facturado", SQL_A, "total", TAGS_AB, "scalar", True),
("a_count", "A · Presupuestos Call center — # Facturas", SQL_A, "count", TAGS_AB, "scalar", False),
("a_ticket", "A · Presupuestos Call center — Ticket medio", SQL_A, "ticket", TAGS_AB, "scalar", True),
("b_total", "B · Presupuestos CallC + Regenerados — Total facturado", SQL_B, "total", TAGS_AB, "scalar", True),
("b_count", "B · Presupuestos CallC + Regenerados — # Facturas", SQL_B, "count", TAGS_AB, "scalar", False),
("b_ticket", "B · Presupuestos CallC + Regenerados — Ticket medio", SQL_B, "ticket", TAGS_AB, "scalar", True),
("c_total", "C · Total facturado — Total facturado", SQL_C, "total", TAGS_C, "scalar", True),
("c_count", "C · Total facturado — # Facturas", SQL_C, "count", TAGS_C, "scalar", False),
("c_ticket", "C · Total facturado — Ticket medio", SQL_C, "ticket", TAGS_C, "scalar", True),
]
cards = {}
for sid, name, skel, agg, tags, display, currency in specs:
print(f"creating {sid} ... {name}")
c = make_card(name, skel, agg, tags, display, currency)
cards[sid] = c
print(f" -> id={c['id']}")
print("\ncreating dashboard v2")
dash = client.post("/api/dashboard", json={
"name": "Call Center — Presupuestos · KPIs (v2)",
"collection_id": COLLECTION_ID,
"description": "A=Quotes CC · B=CC + Regenerados · C=Total facturado (orders con quote). Filtros: fecha-presupuesto/centro/agente/compañía/producto",
}).json()
DID = dash["id"]
print(f" dashboard id={DID}")
DPARAMS = [
{"id": "p_date", "name": "Fecha presupuesto", "slug": "fecha", "type": "date/range", "sectionId": "date"},
{"id": "p_centro", "name": "Centro", "slug": "centro", "type": "id", "sectionId": "id"},
{"id": "p_agente", "name": "Agente CC", "slug": "agente", "type": "id", "sectionId": "id"},
{"id": "p_compania", "name": "Compañía", "slug": "compania", "type": "id", "sectionId": "id"},
{"id": "p_producto", "name": "Producto", "slug": "producto", "type": "id", "sectionId": "id"},
]
def mapping_for_card(card_id, tag_keys):
return [{"parameter_id": f"p_{k}", "card_id": card_id,
"target": ["dimension", ["template-tag", k]]} for k in tag_keys]
DASHCARDS = []
layout = [
("a_total", 0, 0, 4), ("a_count", 0, 8, 4), ("a_ticket", 0, 16, 4),
("b_total", 4, 0, 4), ("b_count", 4, 8, 4), ("b_ticket", 4, 16, 4),
("c_total", 8, 0, 4), ("c_count", 8, 8, 4), ("c_ticket", 8, 16, 4),
]
neg = -1
for sid, row, col, h in layout:
card = cards[sid]
tag_keys = list((TAGS_AB if sid[0] in "ab" else TAGS_C).keys())
DASHCARDS.append({
"id": neg, "card_id": card["id"],
"row": row, "col": col, "size_x": 8, "size_y": h,
"parameter_mappings": mapping_for_card(card["id"], tag_keys),
"visualization_settings": {},
})
neg -= 1
r = client.put(f"/api/dashboard/{DID}", json={"parameters": DPARAMS, "dashcards": DASHCARDS})
if r.status_code >= 400:
print(r.status_code, r.text[:800]); r.raise_for_status()
print(f"\nDONE")
print(f"dashboard URL: {BASE}/dashboard/{DID}")
client.close()
+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()
+2
View File
@@ -0,0 +1,2 @@
users_totales,users_activos
249,497
1 users_totales users_activos
2 249 497
+12
View File
@@ -0,0 +1,12 @@
{
"cols": [
"users_totales",
"users_activos"
],
"rows": [
[
249,
497
]
]
}
+3
View File
@@ -0,0 +1,3 @@
origen,quotes,convertidos,conv_rate
call_center,62779,29576,0.4711
otro,477095,273764,0.5738
1 origen quotes convertidos conv_rate
2 call_center 62779 29576 0.4711
3 otro 477095 273764 0.5738
+22
View File
@@ -0,0 +1,22 @@
{
"cols": [
"origen",
"quotes",
"convertidos",
"conv_rate"
],
"rows": [
[
"call_center",
62779,
29576,
0.4711
],
[
"otro",
477095,
273764,
0.5738
]
]
}
+140
View File
@@ -0,0 +1,140 @@
center_id,center_name,quotes_cc_facturados,A_quote_cc_eur,B_mismo_cliente_eur,C_total_centro_eur,A_sobre_C,B_sobre_C,lift_B_vs_A
144,Velez Malaga,158,21890.28,37700.63,653333.86,0.0335,0.0577,1.72
75,Store,607,150402.5,167424.36,644637.94,0.2333,0.2597,1.11
54,Leganes,516,62620.06,87800.04,637007.16,0.0983,0.1378,1.4
121,Villalba,316,41725.46,57526.94,524985.02,0.0795,0.1096,1.38
168,Goya GLASS,1773,414629.07,418220.06,497398.12,0.8336,0.8408,1.01
73,Malaga,379,64408.08,77124.76,489986.85,0.1314,0.1574,1.2
130,Alcorcon,210,29467.17,42686.28,487245.3,0.0605,0.0876,1.45
35,MT Sanchinarro,480,78658.96,87172.31,487013.5,0.1615,0.179,1.11
82,Vallecas,307,40691.79,57401.47,460004.1,0.0885,0.1248,1.41
146,Vaguada,384,52572.11,66427.6,453792.26,0.1159,0.1464,1.26
74,La Red,149,20090,28795.52,453685.44,0.0443,0.0635,1.43
86,Almeria,206,27675.26,41355.76,447008.24,0.0619,0.0925,1.49
160,Aurgi Web,0,0,32.76,434152.84,0,0.0001,
76,Barbera,146,20540.91,32027.62,428700.38,0.0479,0.0747,1.56
55,MT Pozuelo,148,29616.45,41182.26,428580,0.0691,0.0961,1.39
63,San Fernando,212,40205.6,47217.16,427069.15,0.0941,0.1106,1.17
171,Vallecas CRISTALES,1463,331148.6,333739.92,401405.53,0.825,0.8314,1.01
187,Santa Engracia CRISTALES,1348,329681.23,331496.25,392774.08,0.8394,0.844,1.01
125,Cornella,111,15571.48,21147.91,391400.35,0.0398,0.054,1.36
126,Alcala Henares,72,10900.34,15705.3,380211.91,0.0287,0.0413,1.44
7,Sant Cugat,58,7763.24,13564.45,378412.32,0.0205,0.0358,1.75
169,MT El Bercial CRISTALES,1308,330748.83,332453.44,376719.85,0.878,0.8825,1.01
72,Las Rozas,205,33158.64,47830.21,373018.48,0.0889,0.1282,1.44
81,Granada,299,47903.35,54525.44,371155.24,0.1291,0.1469,1.14
84,Cordoba,48,11288.3,17263.68,366709.5,0.0308,0.0471,1.53
129,Valdemoro,133,17213.85,25731.54,354756.19,0.0485,0.0725,1.49
131,San Juan,237,43924.29,50351.18,349943.56,0.1255,0.1439,1.15
17,Granollers,114,15622.97,24005.78,345773.76,0.0452,0.0694,1.54
127,Alcobendas,200,29518.42,40648.59,343632.11,0.0859,0.1183,1.38
136,Emilio Muñoz,187,24739.45,34694.08,341627.1,0.0724,0.1016,1.4
143,Majadahonda,192,31276.73,44971.82,340391.08,0.0919,0.1321,1.44
15,Islazul,126,21984.03,29037.25,339479.77,0.0648,0.0855,1.32
178,MT San Jose de Valderas CRISTALES,1161,281222.38,281715.46,318152.82,0.8839,0.8855,1
179,Leganes CRISTALES,987,265504.51,266191.45,317917.47,0.8351,0.8373,1
70,San Sebastian,172,25052.46,34852.71,316956.08,0.079,0.11,1.39
85,Gta. Cadiz,165,21422.85,33067.75,314647.04,0.0681,0.1051,1.54
4,Denia,51,8000.13,12828.23,306405.66,0.0261,0.0419,1.6
153,Gava,106,14083.57,21989.08,306073.49,0.046,0.0718,1.56
158,Roquetas,0,0,65.16,298977.66,0,0.0002,
177,Alcobendas CRISTALES,1051,269955.49,270601.04,298382.31,0.9047,0.9069,1
148,Cornella 2,592,96134.21,100573.91,296797.99,0.3239,0.3389,1.05
173,Las Rozas CRISTALES,888,245368.18,246898.16,292203.52,0.8397,0.845,1.01
135,Sabadell,101,13238.18,17443.95,290542.96,0.0456,0.06,1.32
172,Villalba CRISTALES,885,232394.1,232769.47,290040.42,0.8012,0.8025,1
68,MT Cornella,536,103189.78,105522.57,289781.5,0.3561,0.3641,1.02
176,Alcala Henares CRISTALES,873,246470.51,247304.4,289184.89,0.8523,0.8552,1
47,MT Campo de las Naciones,211,39703.45,50583.64,287865.64,0.1379,0.1757,1.27
128,Mostoles,133,21200.57,31128.67,287784.26,0.0737,0.1082,1.47
157,AUR ALICANTE AV. NOVELDA,145,20770.08,31319.51,286873.34,0.0724,0.1092,1.51
154,Torrevieja,36,4073.8,4560.73,278330.4,0.0146,0.0164,1.12
170,San Fernando CRISTALES,908,227844.83,229641.99,275826.67,0.826,0.8326,1.01
145,Fuengirola,123,17562.19,22214.43,264709.16,0.0663,0.0839,1.26
12,Torremolinos,93,12503.16,20883.84,255659.79,0.0489,0.0817,1.67
137,Avda. Toreros,191,24358.6,33144.91,252478.86,0.0965,0.1313,1.36
2,Vall D'Uixo,7,951.37,1177.09,242236.49,0.0039,0.0049,1.24
80,Sant Boi,86,14467.37,18441.03,241629.5,0.0599,0.0763,1.27
5,Aluche,25,5264.01,7271.62,240940.88,0.0218,0.0302,1.38
71,Pinto,78,11014.94,16229.78,237649.58,0.0463,0.0683,1.47
62,MT Siete Palmas,8,1529.55,1529.55,227455.75,0.0067,0.0067,1
196,San Sebastian CRISTALES,696,185155.98,185859.21,224569.04,0.8245,0.8276,1
52,MT Compostela,52,9256.22,10811.55,206916.08,0.0447,0.0523,1.17
151,Puerto de Santa Maria,37,5087.11,6051,206400.33,0.0246,0.0293,1.19
183,Aurgi Asociados Gruas,0,0,0,205234.47,0,0,
152,Villanueva de la Serena,44,5433.46,7159.23,201920.97,0.0269,0.0355,1.32
140,Marques Vadillo,112,13760.46,23911.84,198371.08,0.0694,0.1205,1.74
3,Xativa,36,4944.67,8936.41,196945.1,0.0251,0.0454,1.81
139,Olias del Rey,99,12674.41,16752.24,194246.83,0.0652,0.0862,1.32
16,Zaragoza,70,12267.94,16538.83,193574.84,0.0634,0.0854,1.35
175,MT Pozuelo CRISTALES,582,165660.74,165713.78,192385.99,0.8611,0.8614,1
49,MT San Jose de Valderas,61,14991.76,19566.14,190501.93,0.0787,0.1027,1.31
138,Arganda,46,8350.57,11300.86,189509.48,0.0441,0.0596,1.35
10,Badajoz,77,9368.53,12066.14,182832.34,0.0512,0.066,1.29
14,Huelva,138,15963.93,20659.18,181994.31,0.0877,0.1135,1.29
87,Linares,83,9545.41,12120.64,180444.4,0.0529,0.0672,1.27
9,Sant Celoni,31,5014.96,6555.14,179714.84,0.0279,0.0365,1.31
39,MT Xanadu,46,8257.93,10728.95,174160.49,0.0474,0.0616,1.3
65,MT Bahia de Malaga,230,54588.03,54898.26,173394.01,0.3148,0.3166,1.01
44,MT Alcala de Henares,45,7898.49,11414.08,172249.29,0.0459,0.0663,1.45
150,Alfafar,131,22094.69,28683.36,171669.07,0.1287,0.1671,1.3
114,Rivas,77,11415.44,15251.13,167509.18,0.0681,0.091,1.34
58,MT El Bercial,81,17010.34,23055.27,158228.81,0.1075,0.1457,1.36
185,MT Avenida de Francia CRISTALES,434,130993.68,130993.68,157172.93,0.8334,0.8334,1
69,MT Bahia de Santander,55,8303.33,10238.89,144137.82,0.0576,0.071,1.23
182,MT Monasterio CRISTALES,380,112996.56,113678.99,135043.75,0.8367,0.8418,1.01
186,Santa Engracia,66,9667.07,13594.88,130549.62,0.074,0.1041,1.41
20,MT Castellana,65,8613.86,11728.55,128307.4,0.0671,0.0914,1.36
56,MT Costa de Marbella,29,5393.21,6215.55,128083.53,0.0421,0.0485,1.15
64,MT Bahia de Cadiz,17,2565.2,2822.03,126955.96,0.0202,0.0222,1.1
195,Zaragoza CRISTALES,368,97169.88,98730.64,120766.79,0.8046,0.8175,1.02
51,MT Gijon,1,110.99,110.99,118236.41,0.0009,0.0009,1
155,Elche,54,8385.01,11009.52,111975.73,0.0749,0.0983,1.31
11,Finestrat,68,7470.14,9966.23,111270.18,0.0671,0.0896,1.33
199,Barbera CRISTALES,295,78373.14,78926.29,103207.81,0.7594,0.7647,1.01
188,San Juan CRISTALES,291,79172.97,79686.13,100873.29,0.7849,0.79,1.01
79,Mataro,24,3783.85,5451.63,98348.75,0.0385,0.0554,1.44
57,MT Bahia de Algeciras,46,8659.07,9742.71,92987.24,0.0931,0.1048,1.13
29,MT Ramon y Cajal,54,9549.58,9598.06,90904.79,0.1051,0.1056,1.01
37,MT Tres de Mayo,16,2601.63,2714.24,90660.63,0.0287,0.0299,1.04
34,MT Avenida de Francia,27,3381.63,4224.51,90103.94,0.0375,0.0469,1.25
190,Store CRISTALES,244,59349.38,59581.83,83733.78,0.7088,0.7116,1
32,MT San Juan de Aznalfarache,5,2141.5,3030.57,83374.16,0.0257,0.0363,1.42
48,MT Ronda de Cordoba,2,72.48,72.48,77640.25,0.0009,0.0009,1
41,MT Monasterio,32,5912.41,7227,77453.79,0.0763,0.0933,1.22
18,MT Goya,28,5333.72,6082.91,76017.66,0.0702,0.08,1.14
167,Autingo,0,0,0,75778.19,0,0,
193,MT Cornella CRISTALES,204,49304.4,49304.4,74662.85,0.6604,0.6604,1
40,MT Ctra de Madrid-Irun km. 236,13,2347.9,2575.04,73744.33,0.0318,0.0349,1.1
26,MT Malaga,44,4864.2,5052.17,71516.57,0.068,0.0706,1.04
43,MT Gran Casa,48,7443.89,8108.43,70587.48,0.1055,0.1149,1.09
59,MT Ciudad de Elche,18,2413.82,3232.7,69880.96,0.0345,0.0463,1.34
27,MT Nuevo Centro,38,7164.94,7578.46,68997.21,0.1038,0.1098,1.06
197,MT Ramon y Cajal CRISTALES,199,62096.18,62157.08,67476.67,0.9203,0.9212,1
156,MT Costa Mijas,4,556.5,852.39,65321.85,0.0085,0.013,1.53
60,MT Jaen,124,37246.93,37246.93,64164.78,0.5805,0.5805,1
200,Unidad Movil Madrid Glass,191,39717.04,39777.94,56923.41,0.6977,0.6988,1
31,MT Alexandre Rosello,101,35027.15,35411.35,53680.12,0.6525,0.6597,1.01
36,MT Conquistadores,4,618.56,618.56,52295.77,0.0118,0.0118,1
38,MT Jerez,15,1827.28,1827.28,52093.94,0.0351,0.0351,1
46,MT Puerto Venecia,21,3805.56,5082.72,51536.81,0.0738,0.0986,1.34
33,MT El Corte Ingles Cartagena,11,2309.83,3308.05,51368.02,0.045,0.0644,1.43
206,Arganda CRISTALES,172,47691.39,47691.39,47874.09,0.9962,0.9962,1
23,MT Avenida de la Libertad,12,2541.59,2749.56,45596.45,0.0557,0.0603,1.08
28,MT Nervion,29,3469.91,3504.9,41910.86,0.0828,0.0836,1.01
66,MT Paseo de Morella,12,2767.14,4367.52,40230.44,0.0688,0.1086,1.58
181,Huelva CRISTALES,90,29242.65,29242.65,37407.34,0.7817,0.7817,1
198,MT Compostela CRISTALES,87,28174.23,28174.23,36717.66,0.7673,0.7673,1
204,Implant Centauro Alicante,337,34975.59,34975.59,35324.21,0.9901,0.9901,1
194,MT Maria Auxiliadora CRISTALES,103,27493.09,27875.74,33957.08,0.8096,0.8209,1.01
61,MT Avenida de España,0,0,0,32895.15,0,0,
30,MT Alicante,12,2753.03,2949.78,32304.57,0.0852,0.0913,1.07
25,MT Vigo,11,3193.17,4321.84,32198.1,0.0992,0.1342,1.35
180,Puerto de Santa Maria CRISTALES,96,26934.72,27009.61,31691.8,0.8499,0.8523,1
53,MT Maria Auxiliadora,3,502.88,623.86,30068.91,0.0167,0.0207,1.24
202,Cordoba CRISTALES,76,17913.72,17913.72,29649.87,0.6042,0.6042,1
67,MT Talavera de la Reina,5,281.85,281.85,28310.59,0.01,0.01,1
203,San Fernando MOVIL,354,20543.85,20631.01,21780.37,0.9432,0.9472,1
192,MT Ctra de Madrid-Irun km. 236 CRISTALES,63,18083.01,18083.01,21574.22,0.8382,0.8382,1
161,MT Web,0,0,0,7576.48,0,0,
184,Aurgi Asociados,0,0,0,620,0,0,
1 center_id center_name quotes_cc_facturados A_quote_cc_eur B_mismo_cliente_eur C_total_centro_eur A_sobre_C B_sobre_C lift_B_vs_A
2 144 Velez Malaga 158 21890.28 37700.63 653333.86 0.0335 0.0577 1.72
3 75 Store 607 150402.5 167424.36 644637.94 0.2333 0.2597 1.11
4 54 Leganes 516 62620.06 87800.04 637007.16 0.0983 0.1378 1.4
5 121 Villalba 316 41725.46 57526.94 524985.02 0.0795 0.1096 1.38
6 168 Goya GLASS 1773 414629.07 418220.06 497398.12 0.8336 0.8408 1.01
7 73 Malaga 379 64408.08 77124.76 489986.85 0.1314 0.1574 1.2
8 130 Alcorcon 210 29467.17 42686.28 487245.3 0.0605 0.0876 1.45
9 35 MT Sanchinarro 480 78658.96 87172.31 487013.5 0.1615 0.179 1.11
10 82 Vallecas 307 40691.79 57401.47 460004.1 0.0885 0.1248 1.41
11 146 Vaguada 384 52572.11 66427.6 453792.26 0.1159 0.1464 1.26
12 74 La Red 149 20090 28795.52 453685.44 0.0443 0.0635 1.43
13 86 Almeria 206 27675.26 41355.76 447008.24 0.0619 0.0925 1.49
14 160 Aurgi Web 0 0 32.76 434152.84 0 0.0001
15 76 Barbera 146 20540.91 32027.62 428700.38 0.0479 0.0747 1.56
16 55 MT Pozuelo 148 29616.45 41182.26 428580 0.0691 0.0961 1.39
17 63 San Fernando 212 40205.6 47217.16 427069.15 0.0941 0.1106 1.17
18 171 Vallecas CRISTALES 1463 331148.6 333739.92 401405.53 0.825 0.8314 1.01
19 187 Santa Engracia CRISTALES 1348 329681.23 331496.25 392774.08 0.8394 0.844 1.01
20 125 Cornella 111 15571.48 21147.91 391400.35 0.0398 0.054 1.36
21 126 Alcala Henares 72 10900.34 15705.3 380211.91 0.0287 0.0413 1.44
22 7 Sant Cugat 58 7763.24 13564.45 378412.32 0.0205 0.0358 1.75
23 169 MT El Bercial CRISTALES 1308 330748.83 332453.44 376719.85 0.878 0.8825 1.01
24 72 Las Rozas 205 33158.64 47830.21 373018.48 0.0889 0.1282 1.44
25 81 Granada 299 47903.35 54525.44 371155.24 0.1291 0.1469 1.14
26 84 Cordoba 48 11288.3 17263.68 366709.5 0.0308 0.0471 1.53
27 129 Valdemoro 133 17213.85 25731.54 354756.19 0.0485 0.0725 1.49
28 131 San Juan 237 43924.29 50351.18 349943.56 0.1255 0.1439 1.15
29 17 Granollers 114 15622.97 24005.78 345773.76 0.0452 0.0694 1.54
30 127 Alcobendas 200 29518.42 40648.59 343632.11 0.0859 0.1183 1.38
31 136 Emilio Muñoz 187 24739.45 34694.08 341627.1 0.0724 0.1016 1.4
32 143 Majadahonda 192 31276.73 44971.82 340391.08 0.0919 0.1321 1.44
33 15 Islazul 126 21984.03 29037.25 339479.77 0.0648 0.0855 1.32
34 178 MT San Jose de Valderas CRISTALES 1161 281222.38 281715.46 318152.82 0.8839 0.8855 1
35 179 Leganes CRISTALES 987 265504.51 266191.45 317917.47 0.8351 0.8373 1
36 70 San Sebastian 172 25052.46 34852.71 316956.08 0.079 0.11 1.39
37 85 Gta. Cadiz 165 21422.85 33067.75 314647.04 0.0681 0.1051 1.54
38 4 Denia 51 8000.13 12828.23 306405.66 0.0261 0.0419 1.6
39 153 Gava 106 14083.57 21989.08 306073.49 0.046 0.0718 1.56
40 158 Roquetas 0 0 65.16 298977.66 0 0.0002
41 177 Alcobendas CRISTALES 1051 269955.49 270601.04 298382.31 0.9047 0.9069 1
42 148 Cornella 2 592 96134.21 100573.91 296797.99 0.3239 0.3389 1.05
43 173 Las Rozas CRISTALES 888 245368.18 246898.16 292203.52 0.8397 0.845 1.01
44 135 Sabadell 101 13238.18 17443.95 290542.96 0.0456 0.06 1.32
45 172 Villalba CRISTALES 885 232394.1 232769.47 290040.42 0.8012 0.8025 1
46 68 MT Cornella 536 103189.78 105522.57 289781.5 0.3561 0.3641 1.02
47 176 Alcala Henares CRISTALES 873 246470.51 247304.4 289184.89 0.8523 0.8552 1
48 47 MT Campo de las Naciones 211 39703.45 50583.64 287865.64 0.1379 0.1757 1.27
49 128 Mostoles 133 21200.57 31128.67 287784.26 0.0737 0.1082 1.47
50 157 AUR ALICANTE AV. NOVELDA 145 20770.08 31319.51 286873.34 0.0724 0.1092 1.51
51 154 Torrevieja 36 4073.8 4560.73 278330.4 0.0146 0.0164 1.12
52 170 San Fernando CRISTALES 908 227844.83 229641.99 275826.67 0.826 0.8326 1.01
53 145 Fuengirola 123 17562.19 22214.43 264709.16 0.0663 0.0839 1.26
54 12 Torremolinos 93 12503.16 20883.84 255659.79 0.0489 0.0817 1.67
55 137 Avda. Toreros 191 24358.6 33144.91 252478.86 0.0965 0.1313 1.36
56 2 Vall D'Uixo 7 951.37 1177.09 242236.49 0.0039 0.0049 1.24
57 80 Sant Boi 86 14467.37 18441.03 241629.5 0.0599 0.0763 1.27
58 5 Aluche 25 5264.01 7271.62 240940.88 0.0218 0.0302 1.38
59 71 Pinto 78 11014.94 16229.78 237649.58 0.0463 0.0683 1.47
60 62 MT Siete Palmas 8 1529.55 1529.55 227455.75 0.0067 0.0067 1
61 196 San Sebastian CRISTALES 696 185155.98 185859.21 224569.04 0.8245 0.8276 1
62 52 MT Compostela 52 9256.22 10811.55 206916.08 0.0447 0.0523 1.17
63 151 Puerto de Santa Maria 37 5087.11 6051 206400.33 0.0246 0.0293 1.19
64 183 Aurgi Asociados Gruas 0 0 0 205234.47 0 0
65 152 Villanueva de la Serena 44 5433.46 7159.23 201920.97 0.0269 0.0355 1.32
66 140 Marques Vadillo 112 13760.46 23911.84 198371.08 0.0694 0.1205 1.74
67 3 Xativa 36 4944.67 8936.41 196945.1 0.0251 0.0454 1.81
68 139 Olias del Rey 99 12674.41 16752.24 194246.83 0.0652 0.0862 1.32
69 16 Zaragoza 70 12267.94 16538.83 193574.84 0.0634 0.0854 1.35
70 175 MT Pozuelo CRISTALES 582 165660.74 165713.78 192385.99 0.8611 0.8614 1
71 49 MT San Jose de Valderas 61 14991.76 19566.14 190501.93 0.0787 0.1027 1.31
72 138 Arganda 46 8350.57 11300.86 189509.48 0.0441 0.0596 1.35
73 10 Badajoz 77 9368.53 12066.14 182832.34 0.0512 0.066 1.29
74 14 Huelva 138 15963.93 20659.18 181994.31 0.0877 0.1135 1.29
75 87 Linares 83 9545.41 12120.64 180444.4 0.0529 0.0672 1.27
76 9 Sant Celoni 31 5014.96 6555.14 179714.84 0.0279 0.0365 1.31
77 39 MT Xanadu 46 8257.93 10728.95 174160.49 0.0474 0.0616 1.3
78 65 MT Bahia de Malaga 230 54588.03 54898.26 173394.01 0.3148 0.3166 1.01
79 44 MT Alcala de Henares 45 7898.49 11414.08 172249.29 0.0459 0.0663 1.45
80 150 Alfafar 131 22094.69 28683.36 171669.07 0.1287 0.1671 1.3
81 114 Rivas 77 11415.44 15251.13 167509.18 0.0681 0.091 1.34
82 58 MT El Bercial 81 17010.34 23055.27 158228.81 0.1075 0.1457 1.36
83 185 MT Avenida de Francia CRISTALES 434 130993.68 130993.68 157172.93 0.8334 0.8334 1
84 69 MT Bahia de Santander 55 8303.33 10238.89 144137.82 0.0576 0.071 1.23
85 182 MT Monasterio CRISTALES 380 112996.56 113678.99 135043.75 0.8367 0.8418 1.01
86 186 Santa Engracia 66 9667.07 13594.88 130549.62 0.074 0.1041 1.41
87 20 MT Castellana 65 8613.86 11728.55 128307.4 0.0671 0.0914 1.36
88 56 MT Costa de Marbella 29 5393.21 6215.55 128083.53 0.0421 0.0485 1.15
89 64 MT Bahia de Cadiz 17 2565.2 2822.03 126955.96 0.0202 0.0222 1.1
90 195 Zaragoza CRISTALES 368 97169.88 98730.64 120766.79 0.8046 0.8175 1.02
91 51 MT Gijon 1 110.99 110.99 118236.41 0.0009 0.0009 1
92 155 Elche 54 8385.01 11009.52 111975.73 0.0749 0.0983 1.31
93 11 Finestrat 68 7470.14 9966.23 111270.18 0.0671 0.0896 1.33
94 199 Barbera CRISTALES 295 78373.14 78926.29 103207.81 0.7594 0.7647 1.01
95 188 San Juan CRISTALES 291 79172.97 79686.13 100873.29 0.7849 0.79 1.01
96 79 Mataro 24 3783.85 5451.63 98348.75 0.0385 0.0554 1.44
97 57 MT Bahia de Algeciras 46 8659.07 9742.71 92987.24 0.0931 0.1048 1.13
98 29 MT Ramon y Cajal 54 9549.58 9598.06 90904.79 0.1051 0.1056 1.01
99 37 MT Tres de Mayo 16 2601.63 2714.24 90660.63 0.0287 0.0299 1.04
100 34 MT Avenida de Francia 27 3381.63 4224.51 90103.94 0.0375 0.0469 1.25
101 190 Store CRISTALES 244 59349.38 59581.83 83733.78 0.7088 0.7116 1
102 32 MT San Juan de Aznalfarache 5 2141.5 3030.57 83374.16 0.0257 0.0363 1.42
103 48 MT Ronda de Cordoba 2 72.48 72.48 77640.25 0.0009 0.0009 1
104 41 MT Monasterio 32 5912.41 7227 77453.79 0.0763 0.0933 1.22
105 18 MT Goya 28 5333.72 6082.91 76017.66 0.0702 0.08 1.14
106 167 Autingo 0 0 0 75778.19 0 0
107 193 MT Cornella CRISTALES 204 49304.4 49304.4 74662.85 0.6604 0.6604 1
108 40 MT Ctra de Madrid-Irun km. 236 13 2347.9 2575.04 73744.33 0.0318 0.0349 1.1
109 26 MT Malaga 44 4864.2 5052.17 71516.57 0.068 0.0706 1.04
110 43 MT Gran Casa 48 7443.89 8108.43 70587.48 0.1055 0.1149 1.09
111 59 MT Ciudad de Elche 18 2413.82 3232.7 69880.96 0.0345 0.0463 1.34
112 27 MT Nuevo Centro 38 7164.94 7578.46 68997.21 0.1038 0.1098 1.06
113 197 MT Ramon y Cajal CRISTALES 199 62096.18 62157.08 67476.67 0.9203 0.9212 1
114 156 MT Costa Mijas 4 556.5 852.39 65321.85 0.0085 0.013 1.53
115 60 MT Jaen 124 37246.93 37246.93 64164.78 0.5805 0.5805 1
116 200 Unidad Movil Madrid Glass 191 39717.04 39777.94 56923.41 0.6977 0.6988 1
117 31 MT Alexandre Rosello 101 35027.15 35411.35 53680.12 0.6525 0.6597 1.01
118 36 MT Conquistadores 4 618.56 618.56 52295.77 0.0118 0.0118 1
119 38 MT Jerez 15 1827.28 1827.28 52093.94 0.0351 0.0351 1
120 46 MT Puerto Venecia 21 3805.56 5082.72 51536.81 0.0738 0.0986 1.34
121 33 MT El Corte Ingles Cartagena 11 2309.83 3308.05 51368.02 0.045 0.0644 1.43
122 206 Arganda CRISTALES 172 47691.39 47691.39 47874.09 0.9962 0.9962 1
123 23 MT Avenida de la Libertad 12 2541.59 2749.56 45596.45 0.0557 0.0603 1.08
124 28 MT Nervion 29 3469.91 3504.9 41910.86 0.0828 0.0836 1.01
125 66 MT Paseo de Morella 12 2767.14 4367.52 40230.44 0.0688 0.1086 1.58
126 181 Huelva CRISTALES 90 29242.65 29242.65 37407.34 0.7817 0.7817 1
127 198 MT Compostela CRISTALES 87 28174.23 28174.23 36717.66 0.7673 0.7673 1
128 204 Implant Centauro Alicante 337 34975.59 34975.59 35324.21 0.9901 0.9901 1
129 194 MT Maria Auxiliadora CRISTALES 103 27493.09 27875.74 33957.08 0.8096 0.8209 1.01
130 61 MT Avenida de España 0 0 0 32895.15 0 0
131 30 MT Alicante 12 2753.03 2949.78 32304.57 0.0852 0.0913 1.07
132 25 MT Vigo 11 3193.17 4321.84 32198.1 0.0992 0.1342 1.35
133 180 Puerto de Santa Maria CRISTALES 96 26934.72 27009.61 31691.8 0.8499 0.8523 1
134 53 MT Maria Auxiliadora 3 502.88 623.86 30068.91 0.0167 0.0207 1.24
135 202 Cordoba CRISTALES 76 17913.72 17913.72 29649.87 0.6042 0.6042 1
136 67 MT Talavera de la Reina 5 281.85 281.85 28310.59 0.01 0.01 1
137 203 San Fernando MOVIL 354 20543.85 20631.01 21780.37 0.9432 0.9472 1
138 192 MT Ctra de Madrid-Irun km. 236 CRISTALES 63 18083.01 18083.01 21574.22 0.8382 0.8382 1
139 161 MT Web 0 0 0 7576.48 0 0
140 184 Aurgi Asociados 0 0 0 620 0 0
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
center_id,center_name,q0_regenerados_aqui,regen_events,dias_avg_regen
146,Vaguada,594,1371,4.9
54,Leganes,575,1246,5.7
75,Store,530,1167,8.1
35,MT Sanchinarro,508,1273,5.6
168,Goya GLASS,461,519,7.3
82,Vallecas,427,895,5.6
81,Granada,419,782,5.6
55,MT Pozuelo,410,1097,5.3
73,Malaga,397,833,5.0
121,Villalba,396,927,5.2
68,MT Cornella,376,714,14.1
130,Alcorcon,349,744,3.0
74,La Red,346,852,6.1
86,Almeria,327,735,3.6
148,Cornella 2,327,495,18.8
131,San Juan,326,731,5.7
63,San Fernando,325,668,3.6
143,Majadahonda,324,722,4.2
70,San Sebastian,324,901,2.9
72,Las Rozas,320,678,4.4
127,Alcobendas,306,765,3.8
47,MT Campo de las Naciones,303,730,4.9
85,Gta. Cadiz,288,667,4.6
136,Emilio Muñoz,282,589,4.5
144,Velez Malaga,280,583,5.0
137,Avda. Toreros,277,571,4.3
76,Barbera,257,786,3.8
58,MT El Bercial,236,564,3.5
15,Islazul,234,506,4.3
187,Santa Engracia CRISTALES,230,269,7.4
1 center_id center_name q0_regenerados_aqui regen_events dias_avg_regen
2 146 Vaguada 594 1371 4.9
3 54 Leganes 575 1246 5.7
4 75 Store 530 1167 8.1
5 35 MT Sanchinarro 508 1273 5.6
6 168 Goya GLASS 461 519 7.3
7 82 Vallecas 427 895 5.6
8 81 Granada 419 782 5.6
9 55 MT Pozuelo 410 1097 5.3
10 73 Malaga 397 833 5.0
11 121 Villalba 396 927 5.2
12 68 MT Cornella 376 714 14.1
13 130 Alcorcon 349 744 3.0
14 74 La Red 346 852 6.1
15 86 Almeria 327 735 3.6
16 148 Cornella 2 327 495 18.8
17 131 San Juan 326 731 5.7
18 63 San Fernando 325 668 3.6
19 143 Majadahonda 324 722 4.2
20 70 San Sebastian 324 901 2.9
21 72 Las Rozas 320 678 4.4
22 127 Alcobendas 306 765 3.8
23 47 MT Campo de las Naciones 303 730 4.9
24 85 Gta. Cadiz 288 667 4.6
25 136 Emilio Muñoz 282 589 4.5
26 144 Velez Malaga 280 583 5.0
27 137 Avda. Toreros 277 571 4.3
28 76 Barbera 257 786 3.8
29 58 MT El Bercial 236 564 3.5
30 15 Islazul 234 506 4.3
31 187 Santa Engracia CRISTALES 230 269 7.4
+221
View File
@@ -0,0 +1,221 @@
{
"cols": [
"center_id",
"center_name",
"q0_regenerados_aqui",
"regen_events",
"dias_avg_regen"
],
"rows": [
[
146,
"Vaguada",
594,
1371,
4.9
],
[
54,
"Leganes",
575,
1246,
5.7
],
[
75,
"Store",
530,
1167,
8.1
],
[
35,
"MT Sanchinarro",
508,
1273,
5.6
],
[
168,
"Goya GLASS",
461,
519,
7.3
],
[
82,
"Vallecas",
427,
895,
5.6
],
[
81,
"Granada",
419,
782,
5.6
],
[
55,
"MT Pozuelo",
410,
1097,
5.3
],
[
73,
"Malaga",
397,
833,
5.0
],
[
121,
"Villalba",
396,
927,
5.2
],
[
68,
"MT Cornella",
376,
714,
14.1
],
[
130,
"Alcorcon",
349,
744,
3.0
],
[
74,
"La Red",
346,
852,
6.1
],
[
86,
"Almeria",
327,
735,
3.6
],
[
148,
"Cornella 2",
327,
495,
18.8
],
[
131,
"San Juan",
326,
731,
5.7
],
[
63,
"San Fernando",
325,
668,
3.6
],
[
143,
"Majadahonda",
324,
722,
4.2
],
[
70,
"San Sebastian",
324,
901,
2.9
],
[
72,
"Las Rozas",
320,
678,
4.4
],
[
127,
"Alcobendas",
306,
765,
3.8
],
[
47,
"MT Campo de las Naciones",
303,
730,
4.9
],
[
85,
"Gta. Cadiz",
288,
667,
4.6
],
[
136,
"Emilio Mu\u00f1oz",
282,
589,
4.5
],
[
144,
"Velez Malaga",
280,
583,
5.0
],
[
137,
"Avda. Toreros",
277,
571,
4.3
],
[
76,
"Barbera",
257,
786,
3.8
],
[
58,
"MT El Bercial",
236,
564,
3.5
],
[
15,
"Islazul",
234,
506,
4.3
],
[
187,
"Santa Engracia CRISTALES",
230,
269,
7.4
]
]
}
+3
View File
@@ -0,0 +1,3 @@
bucket,q0_total,q0_facturado_propio,conv_q0_propio
no_regenerado,35507,22390,0.6306
regenerado,18488,7158,0.3872
1 bucket q0_total q0_facturado_propio conv_q0_propio
2 no_regenerado 35507 22390 0.6306
3 regenerado 18488 7158 0.3872
+22
View File
@@ -0,0 +1,22 @@
{
"cols": [
"bucket",
"q0_total",
"q0_facturado_propio",
"conv_q0_propio"
],
"rows": [
[
"no_regenerado",
35507,
22390,
0.6306
],
[
"regenerado",
18488,
7158,
0.3872
]
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"window_days": 90,
"A_quote_cc_eur": 6392965.08,
"B_mismo_cliente_eur": 6923203.81,
"C_total_centros_eur": 29635811.36,
"A_sobre_C": 0.2157,
"B_sobre_C": 0.2336,
"lift_B_vs_A": 1.08,
"centros_activos": 139
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Remove project prefix `autingo-159109.` from cards 10221-10229.
Reason: Metabase field-filter substitution emits `psql_dcpublic.<table>.<col>` (no project).
BigQuery rejects since the rest of the SQL uses `autingo-159109.psql_dcpublic.<table>`.
Mixed qualifiers in same query break.
"""
import subprocess
import httpx
API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0]
BASE = "https://reports.autingo.es"
client = httpx.Client(base_url=BASE, headers={"x-api-key": API_KEY}, timeout=120)
for cid in range(10221, 10230):
card = client.get(f"/api/card/{cid}").json()
dq = card["dataset_query"]
# MBQL5 stages format: dq["stages"][0]["native"] is the string, "template-tags" lives at stage level
stage = dq["stages"][0]
sql = stage["native"]
new_sql = sql.replace("`autingo-159109.psql_dcpublic.", "`psql_dcpublic.")
if new_sql == sql:
print(f" {cid}: no change")
continue
# Rewrite as legacy native query (Metabase writes legacy on PUT)
new_dq = {
"type": "native",
"database": dq.get("database", 6),
"native": {
"query": new_sql,
"template-tags": stage.get("template-tags", {}),
},
}
r = client.put(f"/api/card/{cid}", json={"dataset_query": new_dq})
r.raise_for_status()
print(f" {cid}: updated")
client.close()
print("done")
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""B = (A) UNION (regenerados): vuelve la union al SQL de cards B.
Mantiene los field-filters por NAME (multi-select dropdown) ya aplicados en rewire_filters_by_name.
"""
import subprocess
import httpx
API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0]
BASE = "https://reports.autingo.es"
DB_ID = 6
F_QUOTE_CREATED_AT = 16588
F_CENTER_NAME = 17330
F_TPVUSER_NAME = 17958
F_COMPANY_NAME = 17158
F_PRODUCT_DESC = 16795
client = httpx.Client(base_url=BASE, headers={"x-api-key": API_KEY}, timeout=180)
def field_filter_tag(name, field_id, widget, display_name):
return {
"id": name + "-tag",
"name": name,
"display-name": display_name,
"type": "dimension",
"dimension": ["field", field_id, None],
"widget-type": widget,
"default": None,
}
TAGS_AB = {
"date": field_filter_tag("date", F_QUOTE_CREATED_AT, "date/range", "Fecha presupuesto"),
"centro": field_filter_tag("centro", F_CENTER_NAME, "string/=", "Centro"),
"agente": field_filter_tag("agente", F_TPVUSER_NAME, "string/=", "Agente CC"),
"compania": field_filter_tag("compania", F_COMPANY_NAME, "string/=", "Compañía"),
"producto": field_filter_tag("producto", F_PRODUCT_DESC, "string/contains", "Producto"),
}
SQL_B = """
WITH cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `psql_dcpublic.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
quotes_q1 AS (SELECT id, order_id, created_at, deleted_at FROM `psql_dcpublic.tpv_orders_quote`),
orders_q1 AS (SELECT id, customer_id, vehicle_id, terminal_id FROM `psql_dcpublic.tpv_orders_order`),
terminals_q1 AS (SELECT id, center_id FROM `psql_dcpublic.tpv_terminals`),
cc_anchored AS (
SELECT
`psql_dcpublic.tpv_orders_quote`.id AS q0_id,
`psql_dcpublic.tpv_orders_quote`.order_id AS q0_order,
`psql_dcpublic.tpv_orders_quote`.created_at AS q0_ts,
`psql_dcpublic.tpv_orders_quote`.created_by_id AS cc_agent_id,
`psql_dcpublic.tpv_orders_order`.customer_id AS cust_id,
`psql_dcpublic.tpv_orders_order`.vehicle_id AS veh_id
FROM `psql_dcpublic.tpv_orders_quote`
JOIN cc_users ON `psql_dcpublic.tpv_orders_quote`.created_by_id = cc_users.user_id
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_authorization_tpvuser` ON `psql_dcpublic.tpv_orders_quote`.created_by_id = `psql_dcpublic.tpv_authorization_tpvuser`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
[[AND {{date}}]]
[[AND {{agente}}]]
),
b_orders AS (
-- A: orders directos del Q-CC
SELECT q0_order AS order_id FROM cc_anchored
UNION DISTINCT
-- Regenerados: Q1 dentro 60d desde Q0-CC, distinto order, centro NO-CC, mismo cliente+vehiculo
SELECT q1.order_id AS order_id
FROM cc_anchored a
JOIN quotes_q1 q1
ON q1.deleted_at IS NULL
AND q1.created_at > a.q0_ts
AND q1.created_at <= TIMESTAMP_ADD(a.q0_ts, INTERVAL 60 DAY)
AND q1.order_id != a.q0_order
JOIN orders_q1 o1 ON q1.order_id = o1.id
LEFT JOIN terminals_q1 t1 ON o1.terminal_id = t1.id
WHERE o1.customer_id = a.cust_id
AND o1.vehicle_id = a.veh_id
AND t1.center_id IS NOT NULL
AND t1.center_id NOT IN (159, 162)
),
filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM b_orders
JOIN `psql_dcpublic.tpv_orders_order` ON b_orders.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.products` ON `psql_dcpublic.tpv_orders_orderitem`.product_id = `psql_dcpublic.products`.id
WHERE COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{centro}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
AGG = {
"total": "ROUND(SUM(total_cost), 2)",
"count": "COUNT(*)",
"ticket": "ROUND(SAFE_DIVIDE(SUM(total_cost), NULLIF(COUNT(*), 0)), 2)",
}
CARDS = {
10251: ("total", "B · Presupuestos CallC + Regenerados — Total facturado"),
10252: ("count", "B · Presupuestos CallC + Regenerados — # Facturas"),
10253: ("ticket", "B · Presupuestos CallC + Regenerados — Ticket medio"),
}
for cid, (agg, name) in CARDS.items():
cur = client.get(f"/api/card/{cid}").json()
viz = cur.get("visualization_settings", {})
sql = SQL_B.replace("__AGG__", AGG[agg])
body = {
"name": name,
"description": name,
"dataset_query": {
"type": "native", "database": DB_ID,
"native": {"query": sql, "template-tags": TAGS_AB},
},
"visualization_settings": viz,
}
r = client.put(f"/api/card/{cid}", json=body)
r.raise_for_status()
print(f" card {cid} ({agg}) updated -> name='{name}'")
client.close()
print("done")
+6
View File
@@ -0,0 +1,6 @@
def main():
print("Hello from presupuestos-callcenter!")
if __name__ == "__main__":
main()
@@ -0,0 +1,224 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "2b3d1ae8",
"metadata": {},
"source": [
"# 00 — Resultados ejecutados (vía Metabase, sin ADC)\n",
"\n",
"Resultados de la ejecución del script `run_via_metabase.py` (BigQuery `autingo-159109.psql_dcpublic`).\n",
"\n",
"Ventana: 90 días Q0, 60 días para detectar regeneración.\n",
"Centros call_center excluidos del cómputo: 159 (CALL CENTER AURGI), 162 (CALL CENTER)."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "c39f6e2c",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'window_days': 90,\n",
" 'A_quote_cc_eur': 6392965.08,\n",
" 'B_mismo_cliente_eur': 6923203.81,\n",
" 'C_total_centros_eur': 29635811.36,\n",
" 'A_sobre_C': 0.2157,\n",
" 'B_sobre_C': 0.2336,\n",
" 'lift_B_vs_A': 1.08,\n",
" 'centros_activos': 139}"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import json\n",
"from pathlib import Path\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"BASE = Path('../data/results').resolve()\n",
"\n",
"def load(name):\n",
" return pd.read_csv(BASE / f'{name}.csv')\n",
"\n",
"totales = json.loads((BASE / 'totales_globales.json').read_text())\n",
"totales"
]
},
{
"cell_type": "markdown",
"id": "33adaf14",
"metadata": {},
"source": [
"## Q1 — Tasa de conversión por origen del usuario que generó la quote"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "24471a70",
"metadata": {},
"outputs": [],
"source": [
"load('01_conversion_origen')"
]
},
{
"cell_type": "markdown",
"id": "5847ffda",
"metadata": {},
"source": [
"**Lectura:** \n",
"- Call center genera 62.8K quotes / 90d, convierte 47.1% (29.6K facturas con mismo `order_id`).\n",
"- Otros usuarios generan 477K quotes / 90d, convierten 57.4% (273.8K facturas).\n",
"- Brecha de ~10pp es esperable: el call_center genera quote en frío (cliente no presente), el TPV de centro genera quote casi siempre con el cliente ya en mostrador."
]
},
{
"cell_type": "markdown",
"id": "3484eeb6",
"metadata": {},
"source": [
"## Q2 — 3 KPI por centro\n",
"\n",
"- **A** = € facturados desde quotes creados por call_center (mismo order_id).\n",
"- **B** = € facturados a los mismos clientes (`customer_id` + `vehicle_id`) en centros físicos NO call_center.\n",
"- **C** = € facturados totales del centro (todos los clientes).\n",
"- `lift_B_vs_A`: 1.0 = solo factura el quote inicial; >1 = el centro factura más al cliente que sólo el quote."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f2bc0458",
"metadata": {},
"outputs": [],
"source": [
"df = load('02_kpi_3_por_centro')\n",
"df.head(15)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "201ac95f",
"metadata": {},
"outputs": [],
"source": [
"# Top 15 centros por valor A (más facturado vía call_center)\n",
"top = df.sort_values('A_quote_cc_eur', ascending=False).head(15).iloc[::-1]\n",
"fig, ax = plt.subplots(figsize=(10, 7))\n",
"y = range(len(top))\n",
"ax.barh(y, top.A_quote_cc_eur, label='A (cc -> factura)')\n",
"ax.barh(y, (top.B_mismo_cliente_eur - top.A_quote_cc_eur).clip(lower=0),\n",
" left=top.A_quote_cc_eur, label='B-A (mismo cliente extra)')\n",
"ax.set_yticks(list(y)); ax.set_yticklabels(top.center_name)\n",
"ax.set_xlabel('€ facturados (90d)')\n",
"ax.legend(); ax.set_title('Top 15 centros — quotes call_center -> factura')\n",
"plt.tight_layout(); plt.show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "291fa4af",
"metadata": {},
"outputs": [],
"source": [
"print('TOTALES 90d (excluye centros call_center 159/162):')\n",
"for k, v in totales.items():\n",
" print(f' {k:25} {v}')"
]
},
{
"cell_type": "markdown",
"id": "3d161868",
"metadata": {},
"source": [
"## Q3 — Centros que MÁS regeneran el presupuesto\n",
"\n",
"Definición operativa: para un par `(customer_id, vehicle_id)` cuyo primer presupuesto (Q0) lo abrió el call_center, hay un Q1+ posterior con **distinto `order_id`** abierto en un terminal del centro físico dentro de 60 días."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7831e9fe",
"metadata": {},
"outputs": [],
"source": [
"regen = load('03_regen_por_centro')\n",
"regen"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "83758dc9",
"metadata": {},
"outputs": [],
"source": [
"top_regen = regen.head(15).iloc[::-1]\n",
"fig, ax = plt.subplots(figsize=(10, 6))\n",
"y = range(len(top_regen))\n",
"ax.barh(y, top_regen.q0_regenerados_aqui)\n",
"ax.set_yticks(list(y)); ax.set_yticklabels(top_regen.center_name)\n",
"ax.set_xlabel('# Q0 (de call_center) regenerados en este centro')\n",
"ax.set_title('Top centros regeneradores de presupuesto call_center (90d Q0, 60d window)')\n",
"plt.tight_layout(); plt.show()"
]
},
{
"cell_type": "markdown",
"id": "b0f6c55c",
"metadata": {},
"source": [
"## Q4 — ¿Regenerar perjudica la conversión propia del Q0?\n",
"\n",
"Conversión del Q0 = el invoice se genera contra el MISMO order_id del Q0 (no contra el order_id regenerado en centro). Si regeneran, ese flujo cae."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "423b68cd",
"metadata": {},
"outputs": [],
"source": [
"load('04_regen_vs_conversion')"
]
},
{
"cell_type": "markdown",
"id": "4956e2d1",
"metadata": {},
"source": [
"**Lectura:**\n",
"- 35.5K Q0 sin regeneración convierten al **63.1%** (sobre el order_id original).\n",
"- 18.5K Q0 con regeneración convierten al **38.7%** sobre el order_id original.\n",
"- Los 'regenerados' no se 'pierden' necesariamente — el cliente puede haberse facturado vía un order_id distinto (el del centro). Esa parte está capturada en el KPI **B** del cuadro anterior.\n",
"- Aprox **34.2% de los Q0 call_center** entran en patrón de regeneración (18488 / 53995)."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,214 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "07306d98",
"metadata": {},
"source": [
"# 01 — Exploración: quotes ↔ call_center ↔ factura\n",
"\n",
"Mapa de tablas y joins en `psql_dcpublic` (BigQuery `autingo-159109`).\n",
"\n",
"## Cadena de joins\n",
"\n",
"```\n",
"tpv_authorization_tpvuser_centers (dccenter_id ∈ {159 CALL_CENTER_AURGI, 162 CALL_CENTER})\n",
" │ tpvuser_id\n",
" ▼\n",
"tpv_orders_quote.created_by_id ──► quote por agente call_center\n",
" │ order_id\n",
" ▼\n",
"tpv_orders_order ─► terminal_id ─► tpv_terminals.center_id ─► centers (centro real de facturación)\n",
" │ │ customer_id │ vehicle_id\n",
" ▼ ▼ ▼\n",
"tpv_orders_invoice (status convertido) tpv_customers (tlf) tpv_vehicles_vehicle (matrícula)\n",
"```\n",
"\n",
"Identidad cliente = `(customer_id, vehicle_id)` o (tlf, matrícula) según necesite normalización."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d2b6d545",
"metadata": {},
"outputs": [],
"source": [
"import os, sys\n",
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "markdown",
"id": "3238b92e",
"metadata": {},
"source": [
"## 1. Usuarios call_center"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "34d656ad",
"metadata": {},
"outputs": [],
"source": [
"cc_users = q(f\"\"\"\n",
"SELECT u.id, u.name, u.email, u.is_active, u.role_id,\n",
" STRING_AGG(CAST(uc.dccenter_id AS STRING)) AS centers\n",
"FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser` u\n",
"JOIN `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers` uc\n",
" ON u.id = uc.tpvuser_id\n",
"WHERE uc.dccenter_id IN (159, 162)\n",
"GROUP BY 1,2,3,4,5\n",
"ORDER BY u.is_active DESC, u.id\n",
"\"\"\")\n",
"print(f\"Total usuarios call_center: {len(cc_users)} (activos: {cc_users.is_active.sum()})\")\n",
"cc_users.head(20)"
]
},
{
"cell_type": "markdown",
"id": "9ec102fb",
"metadata": {},
"source": [
"## 2. Schema quote — campos clave"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e5f94b52",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT column_name, data_type\n",
"FROM `{PROJECT}.{DATASET}.INFORMATION_SCHEMA.COLUMNS`\n",
"WHERE table_name='tpv_orders_quote'\n",
"ORDER BY ordinal_position\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "32ffc1d2",
"metadata": {},
"source": [
"## 3. Distribución `status` y `accepted` (últimos 90d)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "609022a9",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT status, accepted, COUNT(*) n\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote`\n",
"WHERE created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND deleted_at IS NULL\n",
"GROUP BY status, accepted\n",
"ORDER BY n DESC\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "73c85198",
"metadata": {},
"source": [
"## 4. Conversion quote → invoice (mismo order_id)\n",
"\n",
"Una quote convierte cuando existe `tpv_orders_invoice` con el mismo `order_id`. Ese invoice fija la facturación real (NAV-sync via `nav_id`)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "46378f84",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT\n",
" COUNT(*) AS quotes,\n",
" COUNT(DISTINCT q.order_id) AS distinct_orders,\n",
" SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END) AS quotes_con_invoice,\n",
" SAFE_DIVIDE(SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END), COUNT(*)) AS conversion_rate\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
"LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i\n",
" ON q.order_id = i.order_id\n",
"WHERE q.created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND q.deleted_at IS NULL\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "df8b402c",
"metadata": {},
"source": [
"## 5. Sanity: quote por call_center vs otro\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "506744d3",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"WITH cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
")\n",
"SELECT\n",
" CASE WHEN cc.user_id IS NOT NULL THEN 'call_center' ELSE 'otro' END AS origen_user,\n",
" COUNT(*) AS quotes,\n",
" SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END) AS convertidos,\n",
" ROUND(SAFE_DIVIDE(SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END), COUNT(*)), 3) AS conv_rate\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
"LEFT JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
"LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON q.order_id = i.order_id\n",
"WHERE q.created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND q.deleted_at IS NULL\n",
"GROUP BY 1\n",
"\"\"\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,244 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "36ae91c9",
"metadata": {},
"source": [
"# 02 — 3 KPI principales\n",
"\n",
"Por **centro real de facturación** (`tpv_terminals.center_id` del invoice) y ventana temporal:\n",
"\n",
"1. **A — Valor facturado de quotes call_center que CONVIRTIERON** \n",
" Quote creado por usuario call_center + existe `tpv_orders_invoice` con el mismo `order_id`. Sumamos `tpv_orders_order.total_cost` o líneas. Centro = centro del invoice.\n",
"\n",
"2. **B — Valor facturado total a esos mismos clientes en centros** \n",
" Misma identidad cliente (`customer_id` y/o `vehicle_id` y/o `tlf`+`matricula` normalizados). Todas las facturas del cliente en ese centro en la misma ventana. Debe ser ≥ A.\n",
"\n",
"3. **C — Facturación total del centro** \n",
" Suma de invoices del centro en la ventana, todos los clientes."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7938921f",
"metadata": {},
"outputs": [],
"source": [
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"WINDOW_DAYS = 90\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "markdown",
"id": "c7cfa785",
"metadata": {},
"source": [
"## Setup: CTEs base reutilizables\n",
"\n",
"Construimos una query maestra con CTEs para A, B, C juntos por centro."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3e06df33",
"metadata": {},
"outputs": [],
"source": [
"SQL_KPI = f\"\"\"\n",
"DECLARE window_days INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL window_days DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"-- Quotes creados por call_center que TIENEN invoice (convertidos)\n",
"cc_converted AS (\n",
" SELECT\n",
" q.id AS quote_id,\n",
" q.order_id,\n",
" q.created_at AS quote_ts,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id,\n",
" o.total_cost,\n",
" i.id AS invoice_id,\n",
" i.created_at AS invoice_ts\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.created_at >= t_start\n",
" AND q.deleted_at IS NULL\n",
"),\n",
"-- Clientes \"tocados\" por call_center (customer_id + vehicle_id)\n",
"cc_clients AS (\n",
" SELECT DISTINCT center_id, customer_id, vehicle_id\n",
" FROM cc_converted\n",
" WHERE customer_id IS NOT NULL\n",
"),\n",
"-- Todas las facturas en la ventana, con centro real\n",
"all_invoices AS (\n",
" SELECT\n",
" i.id AS invoice_id,\n",
" i.order_id,\n",
" i.created_at AS invoice_ts,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id,\n",
" o.total_cost\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_invoice` i\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON i.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE i.created_at >= t_start\n",
"),\n",
"-- B: facturas de los mismos clientes en cualquier centro NO call_center\n",
"client_invoices_in_centers AS (\n",
" SELECT ai.*\n",
" FROM all_invoices ai\n",
" JOIN cc_clients cc ON ai.customer_id = cc.customer_id\n",
" WHERE ai.center_id NOT IN (159, 162) -- excluye los propios centros call_center\n",
"),\n",
"kpi_a AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT quote_id) AS quotes_cc_convertidos,\n",
" COUNT(DISTINCT invoice_id) AS invoices_a,\n",
" SUM(total_cost) AS valor_a\n",
" FROM cc_converted\n",
" WHERE center_id IS NOT NULL\n",
" GROUP BY center_id\n",
"),\n",
"kpi_b AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT invoice_id) AS invoices_b,\n",
" SUM(total_cost) AS valor_b\n",
" FROM client_invoices_in_centers\n",
" GROUP BY center_id\n",
"),\n",
"kpi_c AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT invoice_id) AS invoices_c,\n",
" SUM(total_cost) AS valor_c\n",
" FROM all_invoices\n",
" WHERE center_id IS NOT NULL\n",
" GROUP BY center_id\n",
")\n",
"SELECT\n",
" c.id AS center_id,\n",
" c.name AS center_name,\n",
" COALESCE(a.quotes_cc_convertidos, 0) AS quotes_cc_convertidos,\n",
" ROUND(COALESCE(a.valor_a, 0), 2) AS A_valor_quote_cc_convertido,\n",
" ROUND(COALESCE(b.valor_b, 0), 2) AS B_valor_mismo_cliente_centro,\n",
" ROUND(COALESCE(c2.valor_c, 0), 2) AS C_valor_total_centro,\n",
" ROUND(SAFE_DIVIDE(COALESCE(a.valor_a, 0), c2.valor_c), 4) AS A_sobre_C,\n",
" ROUND(SAFE_DIVIDE(COALESCE(b.valor_b, 0), c2.valor_c), 4) AS B_sobre_C\n",
"FROM `{PROJECT}.{DATASET}.centers` c\n",
"LEFT JOIN kpi_a a ON c.id = a.center_id\n",
"LEFT JOIN kpi_b b ON c.id = b.center_id\n",
"LEFT JOIN kpi_c c2 ON c.id = c2.center_id\n",
"WHERE c.id NOT IN (159, 162)\n",
" AND COALESCE(c2.valor_c, 0) > 0\n",
"ORDER BY C_valor_total_centro DESC\n",
"\"\"\"\n",
"\n",
"df = q(SQL_KPI)\n",
"print(f\"Centros con actividad ({WINDOW_DAYS}d): {len(df)}\")\n",
"df.head(30)"
]
},
{
"cell_type": "markdown",
"id": "724baf5c",
"metadata": {},
"source": [
"## Totales globales"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "75c3297e",
"metadata": {},
"outputs": [],
"source": [
"totals = df[[\"A_valor_quote_cc_convertido\", \"B_valor_mismo_cliente_centro\", \"C_valor_total_centro\"]].sum()\n",
"print(totals.to_string())\n",
"print()\n",
"print(f\"A/C global: {totals.A_valor_quote_cc_convertido / totals.C_valor_total_centro:.4f}\")\n",
"print(f\"B/C global: {totals.B_valor_mismo_cliente_centro / totals.C_valor_total_centro:.4f}\")\n",
"print(f\"Lift B vs A: {totals.B_valor_mismo_cliente_centro / totals.A_valor_quote_cc_convertido:.2f}x\")"
]
},
{
"cell_type": "markdown",
"id": "0fba60db",
"metadata": {},
"source": [
"## Top 15 centros por A (valor traído por call_center)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f90abe7e",
"metadata": {},
"outputs": [],
"source": [
"import matplotlib.pyplot as plt\n",
"\n",
"top = df.sort_values(\"A_valor_quote_cc_convertido\", ascending=False).head(15)\n",
"fig, ax = plt.subplots(figsize=(10, 6))\n",
"x = range(len(top))\n",
"ax.barh(x, top.A_valor_quote_cc_convertido, label=\"A (cc → factura)\")\n",
"ax.barh(x, top.B_valor_mismo_cliente_centro - top.A_valor_quote_cc_convertido,\n",
" left=top.A_valor_quote_cc_convertido, label=\"BA (mismo cliente extra)\")\n",
"ax.set_yticks(x)\n",
"ax.set_yticklabels(top.center_name)\n",
"ax.invert_yaxis()\n",
"ax.set_xlabel(\"€ facturados\")\n",
"ax.legend()\n",
"ax.set_title(f\"Top 15 centros — quotes call_center ({WINDOW_DAYS}d)\")\n",
"plt.tight_layout()\n",
"plt.show()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,367 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "b090f346",
"metadata": {},
"source": [
"# 03 — Regeneración de presupuestos\n",
"\n",
"**Hipótesis:** un mismo cliente (`customer_id` + `vehicle_id`) recibe N quotes antes de convertir. El centro \"regenera\" el presupuesto cuando descarta el de call_center y abre uno nuevo en TPV local.\n",
"\n",
"Definición operativa de regeneración:\n",
"- Existe quote call_center previa (Q0) para el par cliente+vehículo.\n",
"- Existe quote posterior (Q1...Qn) en un terminal de centro NO call_center, dentro de ventana D días.\n",
"- Q1 puede tener distinto `order_id` que Q0 (regenera de cero) o mismo (reescribe — menos común).\n",
"\n",
"Métricas pedidas:\n",
"1. Centros que MÁS regeneran (cuentan regeneraciones absolutas y % sobre quotes call_center recibidos).\n",
"2. Quotes call_center con regeneración vs sin regeneración."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5626c2cd",
"metadata": {},
"outputs": [],
"source": [
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"WINDOW_DAYS = 90 # ventana de análisis sobre quote call_center\n",
"REGEN_WINDOW_DAYS = 60 # ventana para detectar regeneración posterior\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "17cff6ce",
"metadata": {},
"outputs": [],
"source": [
"SQL_REGEN = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"-- Q0: quotes generados por call_center\n",
"q0 AS (\n",
" SELECT\n",
" q.id AS q0_id,\n",
" q.order_id AS q0_order,\n",
" q.created_at AS q0_ts,\n",
" o.customer_id,\n",
" o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start\n",
" AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL\n",
" AND o.vehicle_id IS NOT NULL\n",
"),\n",
"-- Q1..Qn: quotes posteriores para mismo cliente+vehículo, en centro NO call_center\n",
"qN AS (\n",
" SELECT\n",
" q.id AS qn_id,\n",
" q.order_id AS qn_order,\n",
" q.created_at AS qn_ts,\n",
" q.created_by_id,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL\n",
" AND t.center_id NOT IN (159, 162)\n",
"),\n",
"-- Empareja Q0 con Q1+ dentro de regen_win días\n",
"regen AS (\n",
" SELECT\n",
" q0.q0_id,\n",
" q0.q0_order,\n",
" q0.customer_id,\n",
" q0.vehicle_id,\n",
" qN.qn_id,\n",
" qN.qn_order,\n",
" qN.center_id AS regen_center,\n",
" TIMESTAMP_DIFF(qN.qn_ts, q0.q0_ts, HOUR) / 24 AS dias_entre\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"),\n",
"-- Para cada Q0, ¿hay al menos UNA regeneración?\n",
"q0_has_regen AS (\n",
" SELECT q0_id, COUNT(*) AS regen_count,\n",
" MIN(dias_entre) AS dias_a_regen,\n",
" APPROX_TOP_COUNT(regen_center, 1)[OFFSET(0)].value AS first_regen_center\n",
" FROM regen\n",
" GROUP BY q0_id\n",
")\n",
"\n",
"-- Vista por centro: cuántos Q0 regenera cada centro\n",
"SELECT\n",
" c.id AS center_id,\n",
" c.name AS center_name,\n",
" COUNT(DISTINCT r.q0_id) AS q0_regenerados_aqui,\n",
" COUNT(*) AS regen_events,\n",
" ROUND(AVG(r.dias_entre), 1) AS dias_avg_regen\n",
"FROM regen r\n",
"JOIN `{PROJECT}.{DATASET}.centers` c ON r.regen_center = c.id\n",
"GROUP BY c.id, c.name\n",
"ORDER BY q0_regenerados_aqui DESC\n",
"LIMIT 30\n",
"\"\"\"\n",
"\n",
"df_centros = q(SQL_REGEN)\n",
"print(f\"Centros con eventos de regeneración: {len(df_centros)}\")\n",
"df_centros.head(30)"
]
},
{
"cell_type": "markdown",
"id": "43add847",
"metadata": {},
"source": [
"## Totales: Q0 con regeneración vs sin regeneración"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "736158ba",
"metadata": {},
"outputs": [],
"source": [
"SQL_TOT = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
"),\n",
"regen AS (\n",
" SELECT DISTINCT q0.q0_id\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
")\n",
"SELECT\n",
" COUNT(*) AS q0_total,\n",
" COUNT(DISTINCT r.q0_id) AS q0_regenerados,\n",
" COUNT(*) - COUNT(DISTINCT r.q0_id) AS q0_no_regenerados,\n",
" ROUND(SAFE_DIVIDE(COUNT(DISTINCT r.q0_id), COUNT(*)), 4) AS pct_regenerados\n",
"FROM q0\n",
"LEFT JOIN regen r USING (q0_id)\n",
"\"\"\"\n",
"q(SQL_TOT)"
]
},
{
"cell_type": "markdown",
"id": "c183a653",
"metadata": {},
"source": [
"## Distribución días hasta regeneración"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "aab452ca",
"metadata": {},
"outputs": [],
"source": [
"SQL_DIAS = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
")\n",
"SELECT\n",
" TIMESTAMP_DIFF(qN.qn_ts, q0.q0_ts, HOUR)/24 AS dias_entre\n",
"FROM q0\n",
"JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"\"\"\"\n",
"dias = q(SQL_DIAS)\n",
"print(dias.describe())\n",
"import matplotlib.pyplot as plt\n",
"dias[\"dias_entre\"].clip(upper=60).hist(bins=30)\n",
"plt.xlabel(\"Días entre Q0 (call_center) y Q1 (centro)\")\n",
"plt.ylabel(\"# eventos\")\n",
"plt.title(\"Distribución de regeneración temporal\")\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "0feaa9c1",
"metadata": {},
"source": [
"## Cruzar regeneración con conversión a factura\n",
"\n",
"¿Los Q0 regenerados convierten MENOS que los Q0 no regenerados? (Hipótesis: el cliente prefiere lo que negocia el centro)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "376502d8",
"metadata": {},
"outputs": [],
"source": [
"SQL_CONV = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
"),\n",
"regen AS (\n",
" SELECT DISTINCT q0.q0_id\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"),\n",
"q0_inv AS (\n",
" SELECT q0.q0_id,\n",
" CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END AS q0_factura\n",
" FROM q0\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = q0.q0_order\n",
")\n",
"SELECT\n",
" CASE WHEN r.q0_id IS NOT NULL THEN 'regenerado' ELSE 'no_regenerado' END AS bucket,\n",
" COUNT(*) AS q0_total,\n",
" SUM(qi.q0_factura) AS q0_convertido_propio,\n",
" ROUND(SAFE_DIVIDE(SUM(qi.q0_factura), COUNT(*)), 4) AS conv_q0_propio\n",
"FROM q0_inv qi\n",
"LEFT JOIN regen r USING (q0_id)\n",
"GROUP BY bucket\n",
"\"\"\"\n",
"q(SQL_CONV)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
File diff suppressed because one or more lines are too long
+214
View File
@@ -0,0 +1,214 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "07306d98",
"metadata": {},
"source": [
"# 01 — Exploración: quotes ↔ call_center ↔ factura\n",
"\n",
"Mapa de tablas y joins en `psql_dcpublic` (BigQuery `autingo-159109`).\n",
"\n",
"## Cadena de joins\n",
"\n",
"```\n",
"tpv_authorization_tpvuser_centers (dccenter_id ∈ {159 CALL_CENTER_AURGI, 162 CALL_CENTER})\n",
" │ tpvuser_id\n",
" ▼\n",
"tpv_orders_quote.created_by_id ──► quote por agente call_center\n",
" │ order_id\n",
" ▼\n",
"tpv_orders_order ─► terminal_id ─► tpv_terminals.center_id ─► centers (centro real de facturación)\n",
" │ │ customer_id │ vehicle_id\n",
" ▼ ▼ ▼\n",
"tpv_orders_invoice (status convertido) tpv_customers (tlf) tpv_vehicles_vehicle (matrícula)\n",
"```\n",
"\n",
"Identidad cliente = `(customer_id, vehicle_id)` o (tlf, matrícula) según necesite normalización."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d2b6d545",
"metadata": {},
"outputs": [],
"source": [
"import os, sys\n",
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "markdown",
"id": "3238b92e",
"metadata": {},
"source": [
"## 1. Usuarios call_center"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "34d656ad",
"metadata": {},
"outputs": [],
"source": [
"cc_users = q(f\"\"\"\n",
"SELECT u.id, u.name, u.email, u.is_active, u.role_id,\n",
" STRING_AGG(CAST(uc.dccenter_id AS STRING)) AS centers\n",
"FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser` u\n",
"JOIN `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers` uc\n",
" ON u.id = uc.tpvuser_id\n",
"WHERE uc.dccenter_id IN (159, 162)\n",
"GROUP BY 1,2,3,4,5\n",
"ORDER BY u.is_active DESC, u.id\n",
"\"\"\")\n",
"print(f\"Total usuarios call_center: {len(cc_users)} (activos: {cc_users.is_active.sum()})\")\n",
"cc_users.head(20)"
]
},
{
"cell_type": "markdown",
"id": "9ec102fb",
"metadata": {},
"source": [
"## 2. Schema quote — campos clave"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e5f94b52",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT column_name, data_type\n",
"FROM `{PROJECT}.{DATASET}.INFORMATION_SCHEMA.COLUMNS`\n",
"WHERE table_name='tpv_orders_quote'\n",
"ORDER BY ordinal_position\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "32ffc1d2",
"metadata": {},
"source": [
"## 3. Distribución `status` y `accepted` (últimos 90d)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "609022a9",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT status, accepted, COUNT(*) n\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote`\n",
"WHERE created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND deleted_at IS NULL\n",
"GROUP BY status, accepted\n",
"ORDER BY n DESC\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "73c85198",
"metadata": {},
"source": [
"## 4. Conversion quote → invoice (mismo order_id)\n",
"\n",
"Una quote convierte cuando existe `tpv_orders_invoice` con el mismo `order_id`. Ese invoice fija la facturación real (NAV-sync via `nav_id`)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "46378f84",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT\n",
" COUNT(*) AS quotes,\n",
" COUNT(DISTINCT q.order_id) AS distinct_orders,\n",
" SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END) AS quotes_con_invoice,\n",
" SAFE_DIVIDE(SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END), COUNT(*)) AS conversion_rate\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
"LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i\n",
" ON q.order_id = i.order_id\n",
"WHERE q.created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND q.deleted_at IS NULL\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "df8b402c",
"metadata": {},
"source": [
"## 5. Sanity: quote por call_center vs otro\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "506744d3",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"WITH cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
")\n",
"SELECT\n",
" CASE WHEN cc.user_id IS NOT NULL THEN 'call_center' ELSE 'otro' END AS origen_user,\n",
" COUNT(*) AS quotes,\n",
" SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END) AS convertidos,\n",
" ROUND(SAFE_DIVIDE(SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END), COUNT(*)), 3) AS conv_rate\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
"LEFT JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
"LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON q.order_id = i.order_id\n",
"WHERE q.created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND q.deleted_at IS NULL\n",
"GROUP BY 1\n",
"\"\"\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+244
View File
@@ -0,0 +1,244 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "36ae91c9",
"metadata": {},
"source": [
"# 02 — 3 KPI principales\n",
"\n",
"Por **centro real de facturación** (`tpv_terminals.center_id` del invoice) y ventana temporal:\n",
"\n",
"1. **A — Valor facturado de quotes call_center que CONVIRTIERON** \n",
" Quote creado por usuario call_center + existe `tpv_orders_invoice` con el mismo `order_id`. Sumamos `tpv_orders_order.total_cost` o líneas. Centro = centro del invoice.\n",
"\n",
"2. **B — Valor facturado total a esos mismos clientes en centros** \n",
" Misma identidad cliente (`customer_id` y/o `vehicle_id` y/o `tlf`+`matricula` normalizados). Todas las facturas del cliente en ese centro en la misma ventana. Debe ser ≥ A.\n",
"\n",
"3. **C — Facturación total del centro** \n",
" Suma de invoices del centro en la ventana, todos los clientes."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7938921f",
"metadata": {},
"outputs": [],
"source": [
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"WINDOW_DAYS = 90\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "markdown",
"id": "c7cfa785",
"metadata": {},
"source": [
"## Setup: CTEs base reutilizables\n",
"\n",
"Construimos una query maestra con CTEs para A, B, C juntos por centro."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3e06df33",
"metadata": {},
"outputs": [],
"source": [
"SQL_KPI = f\"\"\"\n",
"DECLARE window_days INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL window_days DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"-- Quotes creados por call_center que TIENEN invoice (convertidos)\n",
"cc_converted AS (\n",
" SELECT\n",
" q.id AS quote_id,\n",
" q.order_id,\n",
" q.created_at AS quote_ts,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id,\n",
" o.total_cost,\n",
" i.id AS invoice_id,\n",
" i.created_at AS invoice_ts\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.created_at >= t_start\n",
" AND q.deleted_at IS NULL\n",
"),\n",
"-- Clientes \"tocados\" por call_center (customer_id + vehicle_id)\n",
"cc_clients AS (\n",
" SELECT DISTINCT center_id, customer_id, vehicle_id\n",
" FROM cc_converted\n",
" WHERE customer_id IS NOT NULL\n",
"),\n",
"-- Todas las facturas en la ventana, con centro real\n",
"all_invoices AS (\n",
" SELECT\n",
" i.id AS invoice_id,\n",
" i.order_id,\n",
" i.created_at AS invoice_ts,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id,\n",
" o.total_cost\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_invoice` i\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON i.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE i.created_at >= t_start\n",
"),\n",
"-- B: facturas de los mismos clientes en cualquier centro NO call_center\n",
"client_invoices_in_centers AS (\n",
" SELECT ai.*\n",
" FROM all_invoices ai\n",
" JOIN cc_clients cc ON ai.customer_id = cc.customer_id\n",
" WHERE ai.center_id NOT IN (159, 162) -- excluye los propios centros call_center\n",
"),\n",
"kpi_a AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT quote_id) AS quotes_cc_convertidos,\n",
" COUNT(DISTINCT invoice_id) AS invoices_a,\n",
" SUM(total_cost) AS valor_a\n",
" FROM cc_converted\n",
" WHERE center_id IS NOT NULL\n",
" GROUP BY center_id\n",
"),\n",
"kpi_b AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT invoice_id) AS invoices_b,\n",
" SUM(total_cost) AS valor_b\n",
" FROM client_invoices_in_centers\n",
" GROUP BY center_id\n",
"),\n",
"kpi_c AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT invoice_id) AS invoices_c,\n",
" SUM(total_cost) AS valor_c\n",
" FROM all_invoices\n",
" WHERE center_id IS NOT NULL\n",
" GROUP BY center_id\n",
")\n",
"SELECT\n",
" c.id AS center_id,\n",
" c.name AS center_name,\n",
" COALESCE(a.quotes_cc_convertidos, 0) AS quotes_cc_convertidos,\n",
" ROUND(COALESCE(a.valor_a, 0), 2) AS A_valor_quote_cc_convertido,\n",
" ROUND(COALESCE(b.valor_b, 0), 2) AS B_valor_mismo_cliente_centro,\n",
" ROUND(COALESCE(c2.valor_c, 0), 2) AS C_valor_total_centro,\n",
" ROUND(SAFE_DIVIDE(COALESCE(a.valor_a, 0), c2.valor_c), 4) AS A_sobre_C,\n",
" ROUND(SAFE_DIVIDE(COALESCE(b.valor_b, 0), c2.valor_c), 4) AS B_sobre_C\n",
"FROM `{PROJECT}.{DATASET}.centers` c\n",
"LEFT JOIN kpi_a a ON c.id = a.center_id\n",
"LEFT JOIN kpi_b b ON c.id = b.center_id\n",
"LEFT JOIN kpi_c c2 ON c.id = c2.center_id\n",
"WHERE c.id NOT IN (159, 162)\n",
" AND COALESCE(c2.valor_c, 0) > 0\n",
"ORDER BY C_valor_total_centro DESC\n",
"\"\"\"\n",
"\n",
"df = q(SQL_KPI)\n",
"print(f\"Centros con actividad ({WINDOW_DAYS}d): {len(df)}\")\n",
"df.head(30)"
]
},
{
"cell_type": "markdown",
"id": "724baf5c",
"metadata": {},
"source": [
"## Totales globales"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "75c3297e",
"metadata": {},
"outputs": [],
"source": [
"totals = df[[\"A_valor_quote_cc_convertido\", \"B_valor_mismo_cliente_centro\", \"C_valor_total_centro\"]].sum()\n",
"print(totals.to_string())\n",
"print()\n",
"print(f\"A/C global: {totals.A_valor_quote_cc_convertido / totals.C_valor_total_centro:.4f}\")\n",
"print(f\"B/C global: {totals.B_valor_mismo_cliente_centro / totals.C_valor_total_centro:.4f}\")\n",
"print(f\"Lift B vs A: {totals.B_valor_mismo_cliente_centro / totals.A_valor_quote_cc_convertido:.2f}x\")"
]
},
{
"cell_type": "markdown",
"id": "0fba60db",
"metadata": {},
"source": [
"## Top 15 centros por A (valor traído por call_center)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f90abe7e",
"metadata": {},
"outputs": [],
"source": [
"import matplotlib.pyplot as plt\n",
"\n",
"top = df.sort_values(\"A_valor_quote_cc_convertido\", ascending=False).head(15)\n",
"fig, ax = plt.subplots(figsize=(10, 6))\n",
"x = range(len(top))\n",
"ax.barh(x, top.A_valor_quote_cc_convertido, label=\"A (cc → factura)\")\n",
"ax.barh(x, top.B_valor_mismo_cliente_centro - top.A_valor_quote_cc_convertido,\n",
" left=top.A_valor_quote_cc_convertido, label=\"BA (mismo cliente extra)\")\n",
"ax.set_yticks(x)\n",
"ax.set_yticklabels(top.center_name)\n",
"ax.invert_yaxis()\n",
"ax.set_xlabel(\"€ facturados\")\n",
"ax.legend()\n",
"ax.set_title(f\"Top 15 centros — quotes call_center ({WINDOW_DAYS}d)\")\n",
"plt.tight_layout()\n",
"plt.show()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+367
View File
@@ -0,0 +1,367 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "b090f346",
"metadata": {},
"source": [
"# 03 — Regeneración de presupuestos\n",
"\n",
"**Hipótesis:** un mismo cliente (`customer_id` + `vehicle_id`) recibe N quotes antes de convertir. El centro \"regenera\" el presupuesto cuando descarta el de call_center y abre uno nuevo en TPV local.\n",
"\n",
"Definición operativa de regeneración:\n",
"- Existe quote call_center previa (Q0) para el par cliente+vehículo.\n",
"- Existe quote posterior (Q1...Qn) en un terminal de centro NO call_center, dentro de ventana D días.\n",
"- Q1 puede tener distinto `order_id` que Q0 (regenera de cero) o mismo (reescribe — menos común).\n",
"\n",
"Métricas pedidas:\n",
"1. Centros que MÁS regeneran (cuentan regeneraciones absolutas y % sobre quotes call_center recibidos).\n",
"2. Quotes call_center con regeneración vs sin regeneración."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5626c2cd",
"metadata": {},
"outputs": [],
"source": [
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"WINDOW_DAYS = 90 # ventana de análisis sobre quote call_center\n",
"REGEN_WINDOW_DAYS = 60 # ventana para detectar regeneración posterior\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "17cff6ce",
"metadata": {},
"outputs": [],
"source": [
"SQL_REGEN = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"-- Q0: quotes generados por call_center\n",
"q0 AS (\n",
" SELECT\n",
" q.id AS q0_id,\n",
" q.order_id AS q0_order,\n",
" q.created_at AS q0_ts,\n",
" o.customer_id,\n",
" o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start\n",
" AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL\n",
" AND o.vehicle_id IS NOT NULL\n",
"),\n",
"-- Q1..Qn: quotes posteriores para mismo cliente+vehículo, en centro NO call_center\n",
"qN AS (\n",
" SELECT\n",
" q.id AS qn_id,\n",
" q.order_id AS qn_order,\n",
" q.created_at AS qn_ts,\n",
" q.created_by_id,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL\n",
" AND t.center_id NOT IN (159, 162)\n",
"),\n",
"-- Empareja Q0 con Q1+ dentro de regen_win días\n",
"regen AS (\n",
" SELECT\n",
" q0.q0_id,\n",
" q0.q0_order,\n",
" q0.customer_id,\n",
" q0.vehicle_id,\n",
" qN.qn_id,\n",
" qN.qn_order,\n",
" qN.center_id AS regen_center,\n",
" TIMESTAMP_DIFF(qN.qn_ts, q0.q0_ts, HOUR) / 24 AS dias_entre\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"),\n",
"-- Para cada Q0, ¿hay al menos UNA regeneración?\n",
"q0_has_regen AS (\n",
" SELECT q0_id, COUNT(*) AS regen_count,\n",
" MIN(dias_entre) AS dias_a_regen,\n",
" APPROX_TOP_COUNT(regen_center, 1)[OFFSET(0)].value AS first_regen_center\n",
" FROM regen\n",
" GROUP BY q0_id\n",
")\n",
"\n",
"-- Vista por centro: cuántos Q0 regenera cada centro\n",
"SELECT\n",
" c.id AS center_id,\n",
" c.name AS center_name,\n",
" COUNT(DISTINCT r.q0_id) AS q0_regenerados_aqui,\n",
" COUNT(*) AS regen_events,\n",
" ROUND(AVG(r.dias_entre), 1) AS dias_avg_regen\n",
"FROM regen r\n",
"JOIN `{PROJECT}.{DATASET}.centers` c ON r.regen_center = c.id\n",
"GROUP BY c.id, c.name\n",
"ORDER BY q0_regenerados_aqui DESC\n",
"LIMIT 30\n",
"\"\"\"\n",
"\n",
"df_centros = q(SQL_REGEN)\n",
"print(f\"Centros con eventos de regeneración: {len(df_centros)}\")\n",
"df_centros.head(30)"
]
},
{
"cell_type": "markdown",
"id": "43add847",
"metadata": {},
"source": [
"## Totales: Q0 con regeneración vs sin regeneración"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "736158ba",
"metadata": {},
"outputs": [],
"source": [
"SQL_TOT = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
"),\n",
"regen AS (\n",
" SELECT DISTINCT q0.q0_id\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
")\n",
"SELECT\n",
" COUNT(*) AS q0_total,\n",
" COUNT(DISTINCT r.q0_id) AS q0_regenerados,\n",
" COUNT(*) - COUNT(DISTINCT r.q0_id) AS q0_no_regenerados,\n",
" ROUND(SAFE_DIVIDE(COUNT(DISTINCT r.q0_id), COUNT(*)), 4) AS pct_regenerados\n",
"FROM q0\n",
"LEFT JOIN regen r USING (q0_id)\n",
"\"\"\"\n",
"q(SQL_TOT)"
]
},
{
"cell_type": "markdown",
"id": "c183a653",
"metadata": {},
"source": [
"## Distribución días hasta regeneración"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "aab452ca",
"metadata": {},
"outputs": [],
"source": [
"SQL_DIAS = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
")\n",
"SELECT\n",
" TIMESTAMP_DIFF(qN.qn_ts, q0.q0_ts, HOUR)/24 AS dias_entre\n",
"FROM q0\n",
"JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"\"\"\"\n",
"dias = q(SQL_DIAS)\n",
"print(dias.describe())\n",
"import matplotlib.pyplot as plt\n",
"dias[\"dias_entre\"].clip(upper=60).hist(bins=30)\n",
"plt.xlabel(\"Días entre Q0 (call_center) y Q1 (centro)\")\n",
"plt.ylabel(\"# eventos\")\n",
"plt.title(\"Distribución de regeneración temporal\")\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "0feaa9c1",
"metadata": {},
"source": [
"## Cruzar regeneración con conversión a factura\n",
"\n",
"¿Los Q0 regenerados convierten MENOS que los Q0 no regenerados? (Hipótesis: el cliente prefiere lo que negocia el centro)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "376502d8",
"metadata": {},
"outputs": [],
"source": [
"SQL_CONV = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
"),\n",
"regen AS (\n",
" SELECT DISTINCT q0.q0_id\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"),\n",
"q0_inv AS (\n",
" SELECT q0.q0_id,\n",
" CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END AS q0_factura\n",
" FROM q0\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = q0.q0_order\n",
")\n",
"SELECT\n",
" CASE WHEN r.q0_id IS NOT NULL THEN 'regenerado' ELSE 'no_regenerado' END AS bucket,\n",
" COUNT(*) AS q0_total,\n",
" SUM(qi.q0_factura) AS q0_convertido_propio,\n",
" ROUND(SAFE_DIVIDE(SUM(qi.q0_factura), COUNT(*)), 4) AS conv_q0_propio\n",
"FROM q0_inv qi\n",
"LEFT JOIN regen r USING (q0_id)\n",
"GROUP BY bucket\n",
"\"\"\"\n",
"q(SQL_CONV)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+17
View File
@@ -0,0 +1,17 @@
[project]
name = "presupuestos-callcenter"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"google-cloud-bigquery>=3.41.0",
"jupyter>=1.1.1",
"jupyter-collaboration>=4.4.0",
"jupyter-mcp-server>=1.0.2",
"jupyterlab>=4.5.7",
"matplotlib>=3.10.9",
"numpy>=2.4.6",
"pandas>=3.0.3",
"seaborn>=0.13.2",
]
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Re-wire dashboard 1019 filters to use NAME columns (searchable + multi-select dropdown).
Reason: ID PK fields tienen has_field_values=none -> no dropdown.
Name columns: has_field_values=list (companies) o search (centers/tpvuser/products).
Multi-select via string/= con isMultiSelect=True.
"""
import json
import subprocess
import httpx
API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0]
BASE = "https://reports.autingo.es"
DB_ID = 6
DASHBOARD_ID = 1019
# Field IDs (name/description columns)
F_QUOTE_CREATED_AT = 16588
F_CENTER_NAME = 17330 # centers.name
F_TPVUSER_NAME = 17958 # tpv_authorization_tpvuser.name
F_COMPANY_NAME = 17158 # companies.name
F_PRODUCT_DESC = 16795 # products.description
client = httpx.Client(base_url=BASE, headers={"x-api-key": API_KEY}, timeout=180)
def field_filter_tag(name, field_id, widget, display_name):
return {
"id": name + "-tag",
"name": name,
"display-name": display_name,
"type": "dimension",
"dimension": ["field", field_id, None],
"widget-type": widget,
"default": None,
}
TAGS_AB = {
"date": field_filter_tag("date", F_QUOTE_CREATED_AT, "date/range", "Fecha presupuesto"),
"centro": field_filter_tag("centro", F_CENTER_NAME, "string/=", "Centro"),
"agente": field_filter_tag("agente", F_TPVUSER_NAME, "string/=", "Agente CC"),
"compania": field_filter_tag("compania", F_COMPANY_NAME, "string/=", "Compañía"),
"producto": field_filter_tag("producto", F_PRODUCT_DESC, "string/contains", "Producto"),
}
TAGS_C = {k: v for k, v in TAGS_AB.items() if k != "agente"}
# SQL must reference products table now (join via orderitem.product_id)
SQL_A = """
WITH cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `psql_dcpublic.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM `psql_dcpublic.tpv_orders_quote`
JOIN cc_users ON `psql_dcpublic.tpv_orders_quote`.created_by_id = cc_users.user_id
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_authorization_tpvuser` ON `psql_dcpublic.tpv_orders_quote`.created_by_id = `psql_dcpublic.tpv_authorization_tpvuser`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.products` ON `psql_dcpublic.tpv_orders_orderitem`.product_id = `psql_dcpublic.products`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
AND COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{date}}]]
[[AND {{centro}}]]
[[AND {{agente}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
SQL_B = """
WITH cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `psql_dcpublic.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
quotes_q1 AS (SELECT id, order_id, created_at, deleted_at FROM `psql_dcpublic.tpv_orders_quote`),
orders_q1 AS (SELECT id, customer_id, vehicle_id, terminal_id FROM `psql_dcpublic.tpv_orders_order`),
terminals_q1 AS (SELECT id, center_id FROM `psql_dcpublic.tpv_terminals`),
cc_anchored AS (
SELECT
`psql_dcpublic.tpv_orders_quote`.id AS q0_id,
`psql_dcpublic.tpv_orders_quote`.order_id AS q0_order,
`psql_dcpublic.tpv_orders_quote`.created_at AS q0_ts,
`psql_dcpublic.tpv_orders_quote`.created_by_id AS cc_agent_id,
`psql_dcpublic.tpv_orders_order`.customer_id AS cust_id,
`psql_dcpublic.tpv_orders_order`.vehicle_id AS veh_id
FROM `psql_dcpublic.tpv_orders_quote`
JOIN cc_users ON `psql_dcpublic.tpv_orders_quote`.created_by_id = cc_users.user_id
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_authorization_tpvuser` ON `psql_dcpublic.tpv_orders_quote`.created_by_id = `psql_dcpublic.tpv_authorization_tpvuser`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
[[AND {{date}}]]
[[AND {{agente}}]]
),
b_orders AS (
-- Solo Q1 regenerados (sin Q-CC directos)
SELECT q1.order_id AS order_id
FROM cc_anchored a
JOIN quotes_q1 q1
ON q1.deleted_at IS NULL
AND q1.created_at > a.q0_ts
AND q1.created_at <= TIMESTAMP_ADD(a.q0_ts, INTERVAL 60 DAY)
AND q1.order_id != a.q0_order
JOIN orders_q1 o1 ON q1.order_id = o1.id
LEFT JOIN terminals_q1 t1 ON o1.terminal_id = t1.id
WHERE o1.customer_id = a.cust_id
AND o1.vehicle_id = a.veh_id
AND t1.center_id IS NOT NULL
AND t1.center_id NOT IN (159, 162)
GROUP BY q1.order_id
),
filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM b_orders
JOIN `psql_dcpublic.tpv_orders_order` ON b_orders.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.products` ON `psql_dcpublic.tpv_orders_orderitem`.product_id = `psql_dcpublic.products`.id
WHERE COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{centro}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
SQL_C = """
WITH filtered AS (
SELECT DISTINCT
`psql_dcpublic.tpv_orders_invoice`.id AS invoice_id,
`psql_dcpublic.tpv_orders_order`.total_cost AS total_cost
FROM `psql_dcpublic.tpv_orders_quote`
JOIN `psql_dcpublic.tpv_orders_order` ON `psql_dcpublic.tpv_orders_quote`.order_id = `psql_dcpublic.tpv_orders_order`.id
JOIN `psql_dcpublic.tpv_orders_invoice` ON `psql_dcpublic.tpv_orders_invoice`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.tpv_terminals` ON `psql_dcpublic.tpv_orders_order`.terminal_id = `psql_dcpublic.tpv_terminals`.id
LEFT JOIN `psql_dcpublic.centers` ON `psql_dcpublic.tpv_terminals`.center_id = `psql_dcpublic.centers`.id
LEFT JOIN `psql_dcpublic.tpv_customers` ON `psql_dcpublic.tpv_orders_order`.customer_id = `psql_dcpublic.tpv_customers`.id
LEFT JOIN `psql_dcpublic.companies` ON `psql_dcpublic.tpv_customers`.company_id = `psql_dcpublic.companies`.id
LEFT JOIN `psql_dcpublic.tpv_orders_orderitem` ON `psql_dcpublic.tpv_orders_orderitem`.order_id = `psql_dcpublic.tpv_orders_order`.id
LEFT JOIN `psql_dcpublic.products` ON `psql_dcpublic.tpv_orders_orderitem`.product_id = `psql_dcpublic.products`.id
WHERE `psql_dcpublic.tpv_orders_quote`.deleted_at IS NULL
AND COALESCE(`psql_dcpublic.centers`.id, 0) NOT IN (159, 162)
[[AND {{date}}]]
[[AND {{centro}}]]
[[AND {{compania}}]]
[[AND {{producto}}]]
)
SELECT __AGG__ AS valor FROM filtered
"""
AGG = {
"total": "ROUND(SUM(total_cost), 2)",
"count": "COUNT(*)",
"ticket": "ROUND(SAFE_DIVIDE(SUM(total_cost), NULLIF(COUNT(*), 0)), 2)",
}
# Card ids actuales del dashboard v2
CARDS = {
"a_total": (10248, SQL_A, "total"),
"a_count": (10249, SQL_A, "count"),
"a_ticket": (10250, SQL_A, "ticket"),
"b_total": (10251, SQL_B, "total"),
"b_count": (10252, SQL_B, "count"),
"b_ticket": (10253, SQL_B, "ticket"),
"c_total": (10254, SQL_C, "total"),
"c_count": (10255, SQL_C, "count"),
"c_ticket": (10256, SQL_C, "ticket"),
}
def get_card(cid):
return client.get(f"/api/card/{cid}").json()
def put_card_sql(cid, sql, tags, viz):
body = {
"dataset_query": {
"type": "native", "database": DB_ID,
"native": {"query": sql, "template-tags": tags},
},
"visualization_settings": viz,
}
r = client.put(f"/api/card/{cid}", json=body)
if r.status_code >= 400:
print(r.status_code, r.text[:500])
r.raise_for_status()
print("rewiring cards...")
for sid, (cid, skel, agg) in CARDS.items():
tags = TAGS_AB if sid[0] in "ab" else TAGS_C
sql = skel.replace("__AGG__", AGG[agg])
# preserve current viz settings
cur = get_card(cid)
viz = cur.get("visualization_settings", {})
put_card_sql(cid, sql, tags, viz)
print(f" {sid} card={cid} updated")
print("\nupdating dashboard parameters to multi-select string filters...")
new_params = [
{"id": "p_date", "name": "Fecha presupuesto", "slug": "fecha", "sectionId": "date", "type": "date/range"},
{"id": "p_centro", "name": "Centro", "slug": "centro", "sectionId": "string", "type": "string/=",
"isMultiSelect": True},
{"id": "p_agente", "name": "Agente CC", "slug": "agente", "sectionId": "string", "type": "string/=",
"isMultiSelect": True},
{"id": "p_compania", "name": "Compañía", "slug": "compania", "sectionId": "string", "type": "string/=",
"isMultiSelect": True},
{"id": "p_producto", "name": "Producto", "slug": "producto", "sectionId": "string", "type": "string/contains",
"isMultiSelect": True},
]
# Para que el dropdown se llene con los valores del field-filter dimension,
# Metabase usa has_field_values del field destino automaticamente. No requiere config extra.
# Re-fetch dashcards para mantener layout + remap parameter_mappings (tags actuales)
dash = client.get(f"/api/dashboard/{DASHBOARD_ID}").json()
# Map current dashcards: card_id -> tag_keys (depende si A/B o C)
def tag_keys_for_card(card_id):
name = next((c["name"] for c in [client.get(f"/api/card/{cid}").json() for cid in [card_id]]), "")
return list(TAGS_C.keys()) if name.startswith("C ·") else list(TAGS_AB.keys())
new_dashcards = []
for dc in dash["dashcards"]:
cname = dc["card"]["name"]
tag_keys = list(TAGS_C.keys()) if cname.startswith("C ·") else list(TAGS_AB.keys())
new_dashcards.append({
"id": dc["id"],
"card_id": dc["card_id"],
"row": dc["row"], "col": dc["col"],
"size_x": dc["size_x"], "size_y": dc["size_y"],
"visualization_settings": dc.get("visualization_settings", {}),
"parameter_mappings": [
{"parameter_id": f"p_{k}", "card_id": dc["card_id"],
"target": ["dimension", ["template-tag", k]]}
for k in tag_keys
],
})
r = client.put(f"/api/dashboard/{DASHBOARD_ID}", json={
"parameters": new_params,
"dashcards": new_dashcards,
})
if r.status_code >= 400:
print(r.status_code, r.text[:800])
r.raise_for_status()
print("done")
print(f"URL: {BASE}/dashboard/{DASHBOARD_ID}")
client.close()
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
# Jupyter Lab — modo colaborativo con autodeteccion de puerto
# Generado por write_jupyter_launcher (fn_registry)
find_free_port() {
for port in 8888 8889 8890 8891 8892 8893 8894 8895 8896 8897 8898 8899; do
if ! ss -tln 2>/dev/null | grep -q ":${port} " && \
! lsof -i:"$port" >/dev/null 2>&1; then
echo $port
return
fi
done
echo 8888
}
PORT=${1:-$(find_free_port)}
cd "$(dirname "$0")"
echo $PORT > .jupyter-port
source .venv/bin/activate 2>/dev/null || true
# IPython startup: cargar .ipython/ local (FN_REGISTRY_ROOT, helpers, sys.path)
if [ -d "$(pwd)/.ipython" ]; then
export IPYTHONDIR="$(pwd)/.ipython"
fi
if ! python -c "import jupyter_collaboration" 2>/dev/null; then
echo "ERROR: jupyter-collaboration no esta instalado"
echo "Instala con: uv add jupyter-collaboration"
exit 1
fi
echo "════════════════════════════════════════════════"
echo " Jupyter Lab + Colaboracion en puerto $PORT"
echo "════════════════════════════════════════════════"
echo ""
echo " Abre: http://localhost:$PORT"
echo " Ctrl+C para detener"
echo ""
jupyter lab \
--port=$PORT \
--no-browser \
--ServerApp.token='' \
--ServerApp.password='' \
--ServerApp.disable_check_xsrf=True \
--ServerApp.allow_origin='*' \
--ServerApp.root_dir="$(pwd)" \
--collaborative
+343
View File
@@ -0,0 +1,343 @@
#!/usr/bin/env python3
"""Execute the analysis queries against BigQuery via Metabase (db=6).
Saves results as CSV + JSON in data/results/ and prints summary numbers.
ADC-free: uses Metabase service account credentials.
"""
import os
import sys
import json
import csv
from pathlib import Path
sys.path.insert(0, os.path.join(os.environ.get("FN_REGISTRY_ROOT", "/home/egutierrez/fn_registry"), "python", "functions"))
import subprocess
import httpx
API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0]
BASE = "https://reports.autingo.es"
DB_ID = 6
HERE = Path(__file__).parent
OUT = HERE / "data" / "results"
OUT.mkdir(parents=True, exist_ok=True)
PROJECT = "autingo-159109"
DATASET = "psql_dcpublic"
WINDOW_DAYS = 90
REGEN_WINDOW_DAYS = 60
client = httpx.Client(
base_url=BASE,
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
timeout=300.0,
)
def run_sql(sql: str) -> tuple[list[str], list[list]]:
payload = {
"type": "native",
"database": DB_ID,
"native": {"query": sql},
}
r = client.post("/api/dataset", json=payload)
r.raise_for_status()
data = r.json()["data"]
cols = [c["display_name"] for c in data["cols"]]
rows = data["rows"]
return cols, rows
def save(name: str, cols: list[str], rows: list[list]) -> None:
csv_path = OUT / f"{name}.csv"
with csv_path.open("w", newline="") as f:
w = csv.writer(f)
w.writerow(cols)
w.writerows(rows)
json_path = OUT / f"{name}.json"
with json_path.open("w") as f:
json.dump({"cols": cols, "rows": rows}, f, indent=2, default=str)
print(f" -> {csv_path.name} ({len(rows)} rows)")
# =====================================================================
# QUERY 0 — Sanity: usuarios call_center
# =====================================================================
print("\n[Q0] usuarios call_center")
cols, rows = run_sql(f"""
SELECT
COUNT(DISTINCT u.id) AS users_totales,
SUM(CASE WHEN u.is_active THEN 1 ELSE 0 END) AS users_activos
FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser` u
JOIN `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers` uc
ON u.id = uc.tpvuser_id
WHERE uc.dccenter_id IN (159, 162)
""")
save("00_users_callcenter", cols, rows)
print(f" {dict(zip(cols, rows[0]))}")
# =====================================================================
# QUERY 1 — Conversion rate global por origen
# =====================================================================
print(f"\n[Q1] conversion global ({WINDOW_DAYS}d)")
cols, rows = run_sql(f"""
WITH cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
)
SELECT
CASE WHEN cc.user_id IS NOT NULL THEN 'call_center' ELSE 'otro' END AS origen,
COUNT(*) AS quotes,
SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END) AS convertidos,
ROUND(SAFE_DIVIDE(SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END), COUNT(*)), 4) AS conv_rate
FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q
LEFT JOIN cc_users cc ON q.created_by_id = cc.user_id
LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON q.order_id = i.order_id
WHERE q.created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {WINDOW_DAYS} DAY)
AND q.deleted_at IS NULL
GROUP BY 1
ORDER BY 1
""")
save("01_conversion_origen", cols, rows)
for r in rows:
print(f" {dict(zip(cols, r))}")
# =====================================================================
# QUERY 2 — 3 KPI por centro (A, B, C)
# =====================================================================
print(f"\n[Q2] KPI A/B/C por centro ({WINDOW_DAYS}d)")
cols, rows = run_sql(f"""
DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {WINDOW_DAYS} DAY);
WITH
cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
cc_converted AS (
SELECT
q.id AS quote_id, q.order_id, o.customer_id, o.vehicle_id,
o.terminal_id, t.center_id, o.total_cost
FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q
JOIN cc_users cc ON q.created_by_id = cc.user_id
JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id
JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = o.id
LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id
WHERE q.created_at >= t_start AND q.deleted_at IS NULL
),
cc_clients AS (
SELECT DISTINCT customer_id, vehicle_id
FROM cc_converted
WHERE customer_id IS NOT NULL
),
all_invoices AS (
SELECT
i.id AS invoice_id, i.order_id,
o.customer_id, o.vehicle_id, o.terminal_id,
t.center_id, o.total_cost
FROM `{PROJECT}.{DATASET}.tpv_orders_invoice` i
JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON i.order_id = o.id
LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id
WHERE i.created_at >= t_start
),
client_invoices AS (
SELECT ai.*
FROM all_invoices ai
JOIN cc_clients cc
ON ai.customer_id = cc.customer_id
AND ai.vehicle_id = cc.vehicle_id
WHERE ai.center_id NOT IN (159, 162)
),
kpi_a AS (
SELECT center_id,
COUNT(DISTINCT quote_id) AS quotes_cc_facturados,
ROUND(SUM(total_cost), 2) AS A_eur
FROM cc_converted
WHERE center_id IS NOT NULL AND center_id NOT IN (159,162)
GROUP BY center_id
),
kpi_b AS (
SELECT center_id,
COUNT(DISTINCT invoice_id) AS invoices_b,
ROUND(SUM(total_cost), 2) AS B_eur
FROM client_invoices
GROUP BY center_id
),
kpi_c AS (
SELECT center_id,
COUNT(DISTINCT invoice_id) AS invoices_c,
ROUND(SUM(total_cost), 2) AS C_eur
FROM all_invoices
WHERE center_id IS NOT NULL AND center_id NOT IN (159,162)
GROUP BY center_id
)
SELECT
c.id AS center_id,
c.name AS center_name,
COALESCE(a.quotes_cc_facturados, 0) AS quotes_cc_facturados,
COALESCE(a.A_eur, 0) AS A_quote_cc_eur,
COALESCE(b.B_eur, 0) AS B_mismo_cliente_eur,
COALESCE(c2.C_eur, 0) AS C_total_centro_eur,
ROUND(SAFE_DIVIDE(COALESCE(a.A_eur, 0), c2.C_eur), 4) AS A_sobre_C,
ROUND(SAFE_DIVIDE(COALESCE(b.B_eur, 0), c2.C_eur), 4) AS B_sobre_C,
ROUND(SAFE_DIVIDE(COALESCE(b.B_eur, 0), NULLIF(a.A_eur, 0)), 2) AS lift_B_vs_A
FROM `{PROJECT}.{DATASET}.centers` c
LEFT JOIN kpi_a a ON c.id = a.center_id
LEFT JOIN kpi_b b ON c.id = b.center_id
LEFT JOIN kpi_c c2 ON c.id = c2.center_id
WHERE COALESCE(c2.C_eur, 0) > 0
ORDER BY C_total_centro_eur DESC
""")
save("02_kpi_3_por_centro", cols, rows)
print(f" centros activos: {len(rows)}")
print(f" top5:")
for r in rows[:5]:
print(f" {r[1]:30} A={r[3]:>12,.0f} B={r[4]:>12,.0f} C={r[5]:>12,.0f}")
# Totales globales
A_total = sum(r[3] for r in rows)
B_total = sum(r[4] for r in rows)
C_total = sum(r[5] for r in rows)
print(f"\n TOTALES ({WINDOW_DAYS}d, centros sin call_center 159/162):")
print(f" A (€ quote cc facturados): {A_total:>15,.2f}")
print(f" B (€ mismo cliente centro): {B_total:>15,.2f}")
print(f" C (€ total centros): {C_total:>15,.2f}")
print(f" A/C = {A_total/C_total:.4f} B/C = {B_total/C_total:.4f} lift B/A = {B_total/A_total:.2f}x")
with (OUT / "totales_globales.json").open("w") as f:
json.dump({
"window_days": WINDOW_DAYS,
"A_quote_cc_eur": round(A_total, 2),
"B_mismo_cliente_eur": round(B_total, 2),
"C_total_centros_eur": round(C_total, 2),
"A_sobre_C": round(A_total / C_total, 4),
"B_sobre_C": round(B_total / C_total, 4),
"lift_B_vs_A": round(B_total / A_total, 2),
"centros_activos": len(rows),
}, f, indent=2)
# =====================================================================
# QUERY 3 — Regeneración por centro
# =====================================================================
print(f"\n[Q3] regeneración por centro ({WINDOW_DAYS}d Q0, {REGEN_WINDOW_DAYS}d window)")
cols, rows = run_sql(f"""
DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {WINDOW_DAYS} DAY);
WITH
cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
q0 AS (
SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,
o.customer_id, o.vehicle_id
FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q
JOIN cc_users cc ON q.created_by_id = cc.user_id
JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id
WHERE q.created_at >= t_start AND q.deleted_at IS NULL
AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL
),
qN AS (
SELECT q.id AS qn_id, q.order_id AS qn_order, q.created_at AS qn_ts,
o.customer_id, o.vehicle_id, t.center_id
FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q
JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id
LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id
WHERE q.deleted_at IS NULL
AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)
),
regen AS (
SELECT q0.q0_id, q0.q0_order, q0.customer_id, q0.vehicle_id,
qN.qn_id, qN.qn_order, qN.center_id AS regen_center,
TIMESTAMP_DIFF(qN.qn_ts, q0.q0_ts, HOUR) / 24 AS dias_entre
FROM q0
JOIN qN
ON q0.customer_id = qN.customer_id
AND q0.vehicle_id = qN.vehicle_id
AND qN.qn_ts > q0.q0_ts
AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL {REGEN_WINDOW_DAYS} DAY)
AND qN.qn_order != q0.q0_order
)
SELECT
c.id AS center_id,
c.name AS center_name,
COUNT(DISTINCT r.q0_id) AS q0_regenerados_aqui,
COUNT(*) AS regen_events,
ROUND(AVG(r.dias_entre), 1) AS dias_avg_regen
FROM regen r
JOIN `{PROJECT}.{DATASET}.centers` c ON r.regen_center = c.id
GROUP BY c.id, c.name
ORDER BY q0_regenerados_aqui DESC
LIMIT 30
""")
save("03_regen_por_centro", cols, rows)
print(f" centros con regeneración: {len(rows)}")
print(f" top5:")
for r in rows[:5]:
print(f" {r[1]:30} q0={r[2]:>5} events={r[3]:>5} dias_avg={r[4]}")
# =====================================================================
# QUERY 4 — Totales Q0 con / sin regeneración
# =====================================================================
print(f"\n[Q4] Q0 con/sin regeneración")
cols, rows = run_sql(f"""
DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {WINDOW_DAYS} DAY);
WITH
cc_users AS (
SELECT DISTINCT tpvuser_id AS user_id
FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`
WHERE dccenter_id IN (159, 162)
),
q0 AS (
SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,
o.customer_id, o.vehicle_id
FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q
JOIN cc_users cc ON q.created_by_id = cc.user_id
JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id
WHERE q.created_at >= t_start AND q.deleted_at IS NULL
AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL
),
qN AS (
SELECT q.order_id AS qn_order, q.created_at AS qn_ts,
o.customer_id, o.vehicle_id, t.center_id
FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q
JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id
LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id
WHERE q.deleted_at IS NULL
AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)
),
regen AS (
SELECT DISTINCT q0.q0_id
FROM q0
JOIN qN
ON q0.customer_id = qN.customer_id
AND q0.vehicle_id = qN.vehicle_id
AND qN.qn_ts > q0.q0_ts
AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL {REGEN_WINDOW_DAYS} DAY)
AND qN.qn_order != q0.q0_order
),
q0_inv AS (
SELECT q0.q0_id,
CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END AS q0_factura
FROM q0
LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = q0.q0_order
)
SELECT
CASE WHEN r.q0_id IS NOT NULL THEN 'regenerado' ELSE 'no_regenerado' END AS bucket,
COUNT(*) AS q0_total,
SUM(qi.q0_factura) AS q0_facturado_propio,
ROUND(SAFE_DIVIDE(SUM(qi.q0_factura), COUNT(*)), 4) AS conv_q0_propio
FROM q0_inv qi
LEFT JOIN regen r USING (q0_id)
GROUP BY bucket
ORDER BY bucket
""")
save("04_regen_vs_conversion", cols, rows)
for r in rows:
print(f" {dict(zip(cols, r))}")
print("\nDONE — resultados en data/results/")
client.close()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Configure dashboard 1019 filters as multi-select dropdowns.
PKs no tienen has_field_values=list, asi que Metabase no auto-lista.
Solucion: crear 3 cards "lookup" (id, name) y usarlas como values_source de cada parametro.
Producto (2.7M rows) -> dropdown search (no card-source, busqueda en vivo).
"""
import json
import subprocess
import httpx
API_KEY = subprocess.check_output(["pass", "show", "metabase/aurgi-api-key"], text=True).strip().splitlines()[0]
BASE = "https://reports.autingo.es"
COLLECTION_ID = 559
DASHBOARD_ID = 1019
client = httpx.Client(base_url=BASE, headers={"x-api-key": API_KEY}, timeout=180)
# Lookup cards: id, name (orden alfabetico)
LOOKUPS = {
"centers": """
SELECT
`psql_dcpublic.centers`.id AS id,
`psql_dcpublic.centers`.name AS name
FROM `psql_dcpublic.centers`
WHERE `psql_dcpublic.centers`.id NOT IN (159, 162)
ORDER BY name
""",
"cc_users": """
SELECT DISTINCT
`psql_dcpublic.tpv_authorization_tpvuser`.id AS id,
`psql_dcpublic.tpv_authorization_tpvuser`.name AS name
FROM `psql_dcpublic.tpv_authorization_tpvuser`
JOIN `psql_dcpublic.tpv_authorization_tpvuser_centers`
ON `psql_dcpublic.tpv_authorization_tpvuser`.id = `psql_dcpublic.tpv_authorization_tpvuser_centers`.tpvuser_id
WHERE `psql_dcpublic.tpv_authorization_tpvuser_centers`.dccenter_id IN (159, 162)
AND `psql_dcpublic.tpv_authorization_tpvuser`.is_active
ORDER BY name
""",
"companies": """
SELECT
`psql_dcpublic.companies`.id AS id,
`psql_dcpublic.companies`.name AS name
FROM `psql_dcpublic.companies`
ORDER BY name
""",
}
def make_lookup_card(slug: str, sql: str) -> int:
name = f"_lookup_{slug}"
body = {
"name": name,
"description": f"Lookup table for dashboard filter dropdown ({slug})",
"type": "question",
"display": "table",
"visualization_settings": {},
"dataset_query": {
"type": "native", "database": 6,
"native": {"query": sql.strip(), "template-tags": {}},
},
"collection_id": COLLECTION_ID,
"result_metadata": None,
}
r = client.post("/api/card", json=body)
r.raise_for_status()
cid = r.json()["id"]
print(f" lookup card {slug:12} -> {cid}")
# Run query to populate result_metadata
client.post(f"/api/card/{cid}/query")
return cid
print("creating lookup cards...")
lookup_ids = {slug: make_lookup_card(slug, sql) for slug, sql in LOOKUPS.items()}
# Re-read each card to pick up result_metadata field-ids (needed for value_field/label_field by name)
def field_ref(card_id, col_name):
"""Reference column inside a native source card. Metabase accepts:
['field', '<name>', {'base-type': '...'}]
"""
c = client.get(f"/api/card/{card_id}").json()
for f in c.get("result_metadata", []) or []:
if f["name"] == col_name:
base = f.get("base_type", "type/Integer" if col_name == "id" else "type/Text")
return ["field", col_name, {"base-type": base}]
# fallback
return ["field", col_name, {"base-type": "type/Integer" if col_name == "id" else "type/Text"}]
print("\nupdating dashboard parameters...")
new_params = [
{"id": "p_date", "name": "Fecha presupuesto", "slug": "fecha",
"sectionId": "date", "type": "date/range"},
{"id": "p_centro", "name": "Centro", "slug": "centro",
"sectionId": "id", "type": "id",
"values_query_type": "list",
"values_source_type": "card",
"values_source_config": {
"card_id": lookup_ids["centers"],
"value_field": field_ref(lookup_ids["centers"], "id"),
"label_field": field_ref(lookup_ids["centers"], "name"),
},
"isMultiSelect": True},
{"id": "p_agente", "name": "Agente CC", "slug": "agente",
"sectionId": "id", "type": "id",
"values_query_type": "list",
"values_source_type": "card",
"values_source_config": {
"card_id": lookup_ids["cc_users"],
"value_field": field_ref(lookup_ids["cc_users"], "id"),
"label_field": field_ref(lookup_ids["cc_users"], "name"),
},
"isMultiSelect": True},
{"id": "p_compania", "name": "Compañía", "slug": "compania",
"sectionId": "id", "type": "id",
"values_query_type": "list",
"values_source_type": "card",
"values_source_config": {
"card_id": lookup_ids["companies"],
"value_field": field_ref(lookup_ids["companies"], "id"),
"label_field": field_ref(lookup_ids["companies"], "name"),
},
"isMultiSelect": True},
{"id": "p_producto", "name": "Producto", "slug": "producto",
"sectionId": "id", "type": "id",
"values_query_type": "search",
"values_source_type": None,
"values_source_config": {},
"isMultiSelect": True},
]
r = client.put(f"/api/dashboard/{DASHBOARD_ID}", json={"parameters": new_params})
r.raise_for_status()
print("dashboard updated")
print(f" URL: {BASE}/dashboard/{DASHBOARD_ID}")
client.close()
Generated
+3242
View File
File diff suppressed because it is too large Load Diff