chore: sync from fn-registry agent
This commit is contained in:
@@ -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()")
|
||||
@@ -0,0 +1 @@
|
||||
8888
|
||||
@@ -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.
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
3.13
|
||||
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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 B−A = ",
|
||||
fmt_eur(totales["B_mismo_cliente_eur"] - totales["A_quote_cc_eur"]), "."),
|
||||
|
||||
hr(),
|
||||
|
||||
h(2, "Conversión de quote → factura por origen"),
|
||||
bullet_list([
|
||||
[p(bold(f"Call center: "), f"{conv_cc[1]:,} quotes / 90d → {conv_cc[2]:,} facturas → ",
|
||||
bold(fmt_pct(conv_cc[3])))],
|
||||
[p(bold(f"Otros usuarios: "), f"{conv_otro[1]:,} quotes / 90d → {conv_otro[2]:,} facturas → ",
|
||||
bold(fmt_pct(conv_otro[3])))],
|
||||
]),
|
||||
p("Brecha esperable de ~10 pp: el quote del centro se hace casi siempre con el "
|
||||
"cliente delante; el del call_center es 'en frío' (vía teléfono)."),
|
||||
|
||||
hr(),
|
||||
|
||||
h(2, "Top 10 centros por A (más facturado vía quote call_center)"),
|
||||
bullet_list([
|
||||
[p(bold(r[1]), " — A=", fmt_eur(r[3]), " · B=", fmt_eur(r[4]), " · C=", fmt_eur(r[5]),
|
||||
" · A/C=", fmt_pct(r[6]), " · lift B/A=", str(r[8]) if r[8] is not None else "—", "×")]
|
||||
for r in top10_a
|
||||
]),
|
||||
|
||||
h(2, "Top 10 centros por C (facturación total)"),
|
||||
bullet_list([
|
||||
[p(bold(r[1]), " — A=", fmt_eur(r[3]), " · B=", fmt_eur(r[4]), " · C=", fmt_eur(r[5]),
|
||||
" · A/C=", fmt_pct(r[6]), " · B/C=", fmt_pct(r[7]))]
|
||||
for r in top10_c
|
||||
]),
|
||||
|
||||
hr(),
|
||||
|
||||
h(2, "Regeneración del presupuesto"),
|
||||
p("Regeneración = un mismo par ", code("(customer_id, vehicle_id)"),
|
||||
" con un Q0 abierto por call_center y un Q1 posterior abierto en un terminal de "
|
||||
"centro físico no-call_center con ", code("order_id"), " distinto, dentro de los 60 días siguientes."),
|
||||
p(bold("Volumen Q0 con / sin regeneración:")),
|
||||
bullet_list([
|
||||
[p(bold("No regenerado: "), f"{rvc['rows'][0][1]:,} Q0 — convierte ",
|
||||
bold(fmt_pct(rvc['rows'][0][3])), " sobre su order_id original")],
|
||||
[p(bold("Regenerado: "), f"{rvc['rows'][1][1]:,} Q0 — convierte ",
|
||||
bold(fmt_pct(rvc['rows'][1][3])), " sobre su order_id original")],
|
||||
]),
|
||||
p("Es decir, ~34,2% de los Q0 del call_center entran en patrón de regeneración. "
|
||||
"La conversión sobre el order_id original cae del 63,1% al 38,7% en ese segmento, "
|
||||
"pero el cliente puede haber facturado por el order_id del centro — esa parte queda "
|
||||
"capturada por el KPI B."),
|
||||
|
||||
h(2, "Top 10 centros que MÁS regeneran (90d / window 60d)"),
|
||||
bullet_list([
|
||||
[p(bold(r[1]), " — Q0 regenerados aquí: ", str(r[2]),
|
||||
" · eventos: ", str(r[3]),
|
||||
" · días promedio entre Q0 y regeneración: ", f"{r[4]:.1f}".replace(".", ","))]
|
||||
for r in top10_regen
|
||||
]),
|
||||
|
||||
hr(),
|
||||
|
||||
h(2, "Cuestiones abiertas"),
|
||||
bullet_list([
|
||||
"Filtrar invoices por status válido (excluir rectificativas/anuladas)",
|
||||
"¿Sumar líneas (orderitem.total_price) en vez de order.total_cost? Captura descuentos finales mejor",
|
||||
"Refinar identidad cliente con teléfono normalizado (BI views *_telefono_normalizado)",
|
||||
"Validar si algún centro fuera de 159/162 actúa como call_center mixto",
|
||||
]),
|
||||
|
||||
h(2, "Origen del análisis"),
|
||||
p("Notebooks ejecutados: ",
|
||||
code("projects/aurgi/analysis/presupuestos_callcenter/notebooks/"),
|
||||
" en el repo ", code("fn_registry"),
|
||||
". Datos crudos: ", code("data/results/*.csv"), "."),
|
||||
]
|
||||
|
||||
doc = {"type": "doc", "content": doc_content}
|
||||
|
||||
# ---- validate before POST ----
|
||||
def validate(node, path=""):
|
||||
errs = []
|
||||
if isinstance(node, dict):
|
||||
typ = node.get("type", "?")
|
||||
if typ not in ALLOWED:
|
||||
errs.append(f"{path}: tipo no permitido '{typ}'")
|
||||
for i, c in enumerate(node.get("content", []) or []):
|
||||
errs += validate(c, f"{path}/{typ}[{i}]")
|
||||
return errs
|
||||
|
||||
errs = validate(doc)
|
||||
if errs:
|
||||
print("INVALID DOC:")
|
||||
for e in errs:
|
||||
print(" ", e)
|
||||
sys.exit(1)
|
||||
print("doc valid, posting...")
|
||||
|
||||
# ---- POST ----
|
||||
r = client.post("/api/document", json={
|
||||
"name": "Call Center — Presupuestos → Facturación (90d)",
|
||||
"collection_id": COLLECTION_ID,
|
||||
"document": doc,
|
||||
})
|
||||
if r.status_code >= 400:
|
||||
print(r.status_code, r.text)
|
||||
sys.exit(1)
|
||||
data = r.json()
|
||||
print(f"created document id={data['id']}")
|
||||
print(f"url: {BASE}/document/{data['id']}")
|
||||
client.close()
|
||||
@@ -0,0 +1,2 @@
|
||||
users_totales,users_activos
|
||||
249,497
|
||||
|
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"cols": [
|
||||
"users_totales",
|
||||
"users_activos"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
249,
|
||||
497
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
origen,quotes,convertidos,conv_rate
|
||||
call_center,62779,29576,0.4711
|
||||
otro,477095,273764,0.5738
|
||||
|
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"cols": [
|
||||
"origen",
|
||||
"quotes",
|
||||
"convertidos",
|
||||
"conv_rate"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"call_center",
|
||||
62779,
|
||||
29576,
|
||||
0.4711
|
||||
],
|
||||
[
|
||||
"otro",
|
||||
477095,
|
||||
273764,
|
||||
0.5738
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
@@ -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
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
bucket,q0_total,q0_facturado_propio,conv_q0_propio
|
||||
no_regenerado,35507,22390,0.6306
|
||||
regenerado,18488,7158,0.3872
|
||||
|
@@ -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
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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=\"B−A (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
@@ -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=\"B−A (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
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
Executable
+50
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user