chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-21 18:26:30 +02:00
commit 969c868217
41 changed files with 10149 additions and 0 deletions
@@ -0,0 +1,224 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "2b3d1ae8",
"metadata": {},
"source": [
"# 00 — Resultados ejecutados (vía Metabase, sin ADC)\n",
"\n",
"Resultados de la ejecución del script `run_via_metabase.py` (BigQuery `autingo-159109.psql_dcpublic`).\n",
"\n",
"Ventana: 90 días Q0, 60 días para detectar regeneración.\n",
"Centros call_center excluidos del cómputo: 159 (CALL CENTER AURGI), 162 (CALL CENTER)."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "c39f6e2c",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'window_days': 90,\n",
" 'A_quote_cc_eur': 6392965.08,\n",
" 'B_mismo_cliente_eur': 6923203.81,\n",
" 'C_total_centros_eur': 29635811.36,\n",
" 'A_sobre_C': 0.2157,\n",
" 'B_sobre_C': 0.2336,\n",
" 'lift_B_vs_A': 1.08,\n",
" 'centros_activos': 139}"
]
},
"execution_count": 1,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import json\n",
"from pathlib import Path\n",
"import pandas as pd\n",
"import matplotlib.pyplot as plt\n",
"\n",
"BASE = Path('../data/results').resolve()\n",
"\n",
"def load(name):\n",
" return pd.read_csv(BASE / f'{name}.csv')\n",
"\n",
"totales = json.loads((BASE / 'totales_globales.json').read_text())\n",
"totales"
]
},
{
"cell_type": "markdown",
"id": "33adaf14",
"metadata": {},
"source": [
"## Q1 — Tasa de conversión por origen del usuario que generó la quote"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "24471a70",
"metadata": {},
"outputs": [],
"source": [
"load('01_conversion_origen')"
]
},
{
"cell_type": "markdown",
"id": "5847ffda",
"metadata": {},
"source": [
"**Lectura:** \n",
"- Call center genera 62.8K quotes / 90d, convierte 47.1% (29.6K facturas con mismo `order_id`).\n",
"- Otros usuarios generan 477K quotes / 90d, convierten 57.4% (273.8K facturas).\n",
"- Brecha de ~10pp es esperable: el call_center genera quote en frío (cliente no presente), el TPV de centro genera quote casi siempre con el cliente ya en mostrador."
]
},
{
"cell_type": "markdown",
"id": "3484eeb6",
"metadata": {},
"source": [
"## Q2 — 3 KPI por centro\n",
"\n",
"- **A** = € facturados desde quotes creados por call_center (mismo order_id).\n",
"- **B** = € facturados a los mismos clientes (`customer_id` + `vehicle_id`) en centros físicos NO call_center.\n",
"- **C** = € facturados totales del centro (todos los clientes).\n",
"- `lift_B_vs_A`: 1.0 = solo factura el quote inicial; >1 = el centro factura más al cliente que sólo el quote."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f2bc0458",
"metadata": {},
"outputs": [],
"source": [
"df = load('02_kpi_3_por_centro')\n",
"df.head(15)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "201ac95f",
"metadata": {},
"outputs": [],
"source": [
"# Top 15 centros por valor A (más facturado vía call_center)\n",
"top = df.sort_values('A_quote_cc_eur', ascending=False).head(15).iloc[::-1]\n",
"fig, ax = plt.subplots(figsize=(10, 7))\n",
"y = range(len(top))\n",
"ax.barh(y, top.A_quote_cc_eur, label='A (cc -> factura)')\n",
"ax.barh(y, (top.B_mismo_cliente_eur - top.A_quote_cc_eur).clip(lower=0),\n",
" left=top.A_quote_cc_eur, label='B-A (mismo cliente extra)')\n",
"ax.set_yticks(list(y)); ax.set_yticklabels(top.center_name)\n",
"ax.set_xlabel('€ facturados (90d)')\n",
"ax.legend(); ax.set_title('Top 15 centros — quotes call_center -> factura')\n",
"plt.tight_layout(); plt.show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "291fa4af",
"metadata": {},
"outputs": [],
"source": [
"print('TOTALES 90d (excluye centros call_center 159/162):')\n",
"for k, v in totales.items():\n",
" print(f' {k:25} {v}')"
]
},
{
"cell_type": "markdown",
"id": "3d161868",
"metadata": {},
"source": [
"## Q3 — Centros que MÁS regeneran el presupuesto\n",
"\n",
"Definición operativa: para un par `(customer_id, vehicle_id)` cuyo primer presupuesto (Q0) lo abrió el call_center, hay un Q1+ posterior con **distinto `order_id`** abierto en un terminal del centro físico dentro de 60 días."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7831e9fe",
"metadata": {},
"outputs": [],
"source": [
"regen = load('03_regen_por_centro')\n",
"regen"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "83758dc9",
"metadata": {},
"outputs": [],
"source": [
"top_regen = regen.head(15).iloc[::-1]\n",
"fig, ax = plt.subplots(figsize=(10, 6))\n",
"y = range(len(top_regen))\n",
"ax.barh(y, top_regen.q0_regenerados_aqui)\n",
"ax.set_yticks(list(y)); ax.set_yticklabels(top_regen.center_name)\n",
"ax.set_xlabel('# Q0 (de call_center) regenerados en este centro')\n",
"ax.set_title('Top centros regeneradores de presupuesto call_center (90d Q0, 60d window)')\n",
"plt.tight_layout(); plt.show()"
]
},
{
"cell_type": "markdown",
"id": "b0f6c55c",
"metadata": {},
"source": [
"## Q4 — ¿Regenerar perjudica la conversión propia del Q0?\n",
"\n",
"Conversión del Q0 = el invoice se genera contra el MISMO order_id del Q0 (no contra el order_id regenerado en centro). Si regeneran, ese flujo cae."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "423b68cd",
"metadata": {},
"outputs": [],
"source": [
"load('04_regen_vs_conversion')"
]
},
{
"cell_type": "markdown",
"id": "4956e2d1",
"metadata": {},
"source": [
"**Lectura:**\n",
"- 35.5K Q0 sin regeneración convierten al **63.1%** (sobre el order_id original).\n",
"- 18.5K Q0 con regeneración convierten al **38.7%** sobre el order_id original.\n",
"- Los 'regenerados' no se 'pierden' necesariamente — el cliente puede haberse facturado vía un order_id distinto (el del centro). Esa parte está capturada en el KPI **B** del cuadro anterior.\n",
"- Aprox **34.2% de los Q0 call_center** entran en patrón de regeneración (18488 / 53995)."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,214 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "07306d98",
"metadata": {},
"source": [
"# 01 — Exploración: quotes ↔ call_center ↔ factura\n",
"\n",
"Mapa de tablas y joins en `psql_dcpublic` (BigQuery `autingo-159109`).\n",
"\n",
"## Cadena de joins\n",
"\n",
"```\n",
"tpv_authorization_tpvuser_centers (dccenter_id ∈ {159 CALL_CENTER_AURGI, 162 CALL_CENTER})\n",
" │ tpvuser_id\n",
" ▼\n",
"tpv_orders_quote.created_by_id ──► quote por agente call_center\n",
" │ order_id\n",
" ▼\n",
"tpv_orders_order ─► terminal_id ─► tpv_terminals.center_id ─► centers (centro real de facturación)\n",
" │ │ customer_id │ vehicle_id\n",
" ▼ ▼ ▼\n",
"tpv_orders_invoice (status convertido) tpv_customers (tlf) tpv_vehicles_vehicle (matrícula)\n",
"```\n",
"\n",
"Identidad cliente = `(customer_id, vehicle_id)` o (tlf, matrícula) según necesite normalización."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "d2b6d545",
"metadata": {},
"outputs": [],
"source": [
"import os, sys\n",
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "markdown",
"id": "3238b92e",
"metadata": {},
"source": [
"## 1. Usuarios call_center"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "34d656ad",
"metadata": {},
"outputs": [],
"source": [
"cc_users = q(f\"\"\"\n",
"SELECT u.id, u.name, u.email, u.is_active, u.role_id,\n",
" STRING_AGG(CAST(uc.dccenter_id AS STRING)) AS centers\n",
"FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser` u\n",
"JOIN `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers` uc\n",
" ON u.id = uc.tpvuser_id\n",
"WHERE uc.dccenter_id IN (159, 162)\n",
"GROUP BY 1,2,3,4,5\n",
"ORDER BY u.is_active DESC, u.id\n",
"\"\"\")\n",
"print(f\"Total usuarios call_center: {len(cc_users)} (activos: {cc_users.is_active.sum()})\")\n",
"cc_users.head(20)"
]
},
{
"cell_type": "markdown",
"id": "9ec102fb",
"metadata": {},
"source": [
"## 2. Schema quote — campos clave"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "e5f94b52",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT column_name, data_type\n",
"FROM `{PROJECT}.{DATASET}.INFORMATION_SCHEMA.COLUMNS`\n",
"WHERE table_name='tpv_orders_quote'\n",
"ORDER BY ordinal_position\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "32ffc1d2",
"metadata": {},
"source": [
"## 3. Distribución `status` y `accepted` (últimos 90d)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "609022a9",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT status, accepted, COUNT(*) n\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote`\n",
"WHERE created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND deleted_at IS NULL\n",
"GROUP BY status, accepted\n",
"ORDER BY n DESC\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "73c85198",
"metadata": {},
"source": [
"## 4. Conversion quote → invoice (mismo order_id)\n",
"\n",
"Una quote convierte cuando existe `tpv_orders_invoice` con el mismo `order_id`. Ese invoice fija la facturación real (NAV-sync via `nav_id`)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "46378f84",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"SELECT\n",
" COUNT(*) AS quotes,\n",
" COUNT(DISTINCT q.order_id) AS distinct_orders,\n",
" SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END) AS quotes_con_invoice,\n",
" SAFE_DIVIDE(SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END), COUNT(*)) AS conversion_rate\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
"LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i\n",
" ON q.order_id = i.order_id\n",
"WHERE q.created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND q.deleted_at IS NULL\n",
"\"\"\")"
]
},
{
"cell_type": "markdown",
"id": "df8b402c",
"metadata": {},
"source": [
"## 5. Sanity: quote por call_center vs otro\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "506744d3",
"metadata": {},
"outputs": [],
"source": [
"q(f\"\"\"\n",
"WITH cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
")\n",
"SELECT\n",
" CASE WHEN cc.user_id IS NOT NULL THEN 'call_center' ELSE 'otro' END AS origen_user,\n",
" COUNT(*) AS quotes,\n",
" SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END) AS convertidos,\n",
" ROUND(SAFE_DIVIDE(SUM(CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END), COUNT(*)), 3) AS conv_rate\n",
"FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
"LEFT JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
"LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON q.order_id = i.order_id\n",
"WHERE q.created_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY)\n",
" AND q.deleted_at IS NULL\n",
"GROUP BY 1\n",
"\"\"\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,244 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "36ae91c9",
"metadata": {},
"source": [
"# 02 — 3 KPI principales\n",
"\n",
"Por **centro real de facturación** (`tpv_terminals.center_id` del invoice) y ventana temporal:\n",
"\n",
"1. **A — Valor facturado de quotes call_center que CONVIRTIERON** \n",
" Quote creado por usuario call_center + existe `tpv_orders_invoice` con el mismo `order_id`. Sumamos `tpv_orders_order.total_cost` o líneas. Centro = centro del invoice.\n",
"\n",
"2. **B — Valor facturado total a esos mismos clientes en centros** \n",
" Misma identidad cliente (`customer_id` y/o `vehicle_id` y/o `tlf`+`matricula` normalizados). Todas las facturas del cliente en ese centro en la misma ventana. Debe ser ≥ A.\n",
"\n",
"3. **C — Facturación total del centro** \n",
" Suma de invoices del centro en la ventana, todos los clientes."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7938921f",
"metadata": {},
"outputs": [],
"source": [
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"WINDOW_DAYS = 90\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "markdown",
"id": "c7cfa785",
"metadata": {},
"source": [
"## Setup: CTEs base reutilizables\n",
"\n",
"Construimos una query maestra con CTEs para A, B, C juntos por centro."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "3e06df33",
"metadata": {},
"outputs": [],
"source": [
"SQL_KPI = f\"\"\"\n",
"DECLARE window_days INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL window_days DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"-- Quotes creados por call_center que TIENEN invoice (convertidos)\n",
"cc_converted AS (\n",
" SELECT\n",
" q.id AS quote_id,\n",
" q.order_id,\n",
" q.created_at AS quote_ts,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id,\n",
" o.total_cost,\n",
" i.id AS invoice_id,\n",
" i.created_at AS invoice_ts\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.created_at >= t_start\n",
" AND q.deleted_at IS NULL\n",
"),\n",
"-- Clientes \"tocados\" por call_center (customer_id + vehicle_id)\n",
"cc_clients AS (\n",
" SELECT DISTINCT center_id, customer_id, vehicle_id\n",
" FROM cc_converted\n",
" WHERE customer_id IS NOT NULL\n",
"),\n",
"-- Todas las facturas en la ventana, con centro real\n",
"all_invoices AS (\n",
" SELECT\n",
" i.id AS invoice_id,\n",
" i.order_id,\n",
" i.created_at AS invoice_ts,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id,\n",
" o.total_cost\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_invoice` i\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON i.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE i.created_at >= t_start\n",
"),\n",
"-- B: facturas de los mismos clientes en cualquier centro NO call_center\n",
"client_invoices_in_centers AS (\n",
" SELECT ai.*\n",
" FROM all_invoices ai\n",
" JOIN cc_clients cc ON ai.customer_id = cc.customer_id\n",
" WHERE ai.center_id NOT IN (159, 162) -- excluye los propios centros call_center\n",
"),\n",
"kpi_a AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT quote_id) AS quotes_cc_convertidos,\n",
" COUNT(DISTINCT invoice_id) AS invoices_a,\n",
" SUM(total_cost) AS valor_a\n",
" FROM cc_converted\n",
" WHERE center_id IS NOT NULL\n",
" GROUP BY center_id\n",
"),\n",
"kpi_b AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT invoice_id) AS invoices_b,\n",
" SUM(total_cost) AS valor_b\n",
" FROM client_invoices_in_centers\n",
" GROUP BY center_id\n",
"),\n",
"kpi_c AS (\n",
" SELECT center_id,\n",
" COUNT(DISTINCT invoice_id) AS invoices_c,\n",
" SUM(total_cost) AS valor_c\n",
" FROM all_invoices\n",
" WHERE center_id IS NOT NULL\n",
" GROUP BY center_id\n",
")\n",
"SELECT\n",
" c.id AS center_id,\n",
" c.name AS center_name,\n",
" COALESCE(a.quotes_cc_convertidos, 0) AS quotes_cc_convertidos,\n",
" ROUND(COALESCE(a.valor_a, 0), 2) AS A_valor_quote_cc_convertido,\n",
" ROUND(COALESCE(b.valor_b, 0), 2) AS B_valor_mismo_cliente_centro,\n",
" ROUND(COALESCE(c2.valor_c, 0), 2) AS C_valor_total_centro,\n",
" ROUND(SAFE_DIVIDE(COALESCE(a.valor_a, 0), c2.valor_c), 4) AS A_sobre_C,\n",
" ROUND(SAFE_DIVIDE(COALESCE(b.valor_b, 0), c2.valor_c), 4) AS B_sobre_C\n",
"FROM `{PROJECT}.{DATASET}.centers` c\n",
"LEFT JOIN kpi_a a ON c.id = a.center_id\n",
"LEFT JOIN kpi_b b ON c.id = b.center_id\n",
"LEFT JOIN kpi_c c2 ON c.id = c2.center_id\n",
"WHERE c.id NOT IN (159, 162)\n",
" AND COALESCE(c2.valor_c, 0) > 0\n",
"ORDER BY C_valor_total_centro DESC\n",
"\"\"\"\n",
"\n",
"df = q(SQL_KPI)\n",
"print(f\"Centros con actividad ({WINDOW_DAYS}d): {len(df)}\")\n",
"df.head(30)"
]
},
{
"cell_type": "markdown",
"id": "724baf5c",
"metadata": {},
"source": [
"## Totales globales"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "75c3297e",
"metadata": {},
"outputs": [],
"source": [
"totals = df[[\"A_valor_quote_cc_convertido\", \"B_valor_mismo_cliente_centro\", \"C_valor_total_centro\"]].sum()\n",
"print(totals.to_string())\n",
"print()\n",
"print(f\"A/C global: {totals.A_valor_quote_cc_convertido / totals.C_valor_total_centro:.4f}\")\n",
"print(f\"B/C global: {totals.B_valor_mismo_cliente_centro / totals.C_valor_total_centro:.4f}\")\n",
"print(f\"Lift B vs A: {totals.B_valor_mismo_cliente_centro / totals.A_valor_quote_cc_convertido:.2f}x\")"
]
},
{
"cell_type": "markdown",
"id": "0fba60db",
"metadata": {},
"source": [
"## Top 15 centros por A (valor traído por call_center)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "f90abe7e",
"metadata": {},
"outputs": [],
"source": [
"import matplotlib.pyplot as plt\n",
"\n",
"top = df.sort_values(\"A_valor_quote_cc_convertido\", ascending=False).head(15)\n",
"fig, ax = plt.subplots(figsize=(10, 6))\n",
"x = range(len(top))\n",
"ax.barh(x, top.A_valor_quote_cc_convertido, label=\"A (cc → factura)\")\n",
"ax.barh(x, top.B_valor_mismo_cliente_centro - top.A_valor_quote_cc_convertido,\n",
" left=top.A_valor_quote_cc_convertido, label=\"BA (mismo cliente extra)\")\n",
"ax.set_yticks(x)\n",
"ax.set_yticklabels(top.center_name)\n",
"ax.invert_yaxis()\n",
"ax.set_xlabel(\"€ facturados\")\n",
"ax.legend()\n",
"ax.set_title(f\"Top 15 centros — quotes call_center ({WINDOW_DAYS}d)\")\n",
"plt.tight_layout()\n",
"plt.show()"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -0,0 +1,367 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "b090f346",
"metadata": {},
"source": [
"# 03 — Regeneración de presupuestos\n",
"\n",
"**Hipótesis:** un mismo cliente (`customer_id` + `vehicle_id`) recibe N quotes antes de convertir. El centro \"regenera\" el presupuesto cuando descarta el de call_center y abre uno nuevo en TPV local.\n",
"\n",
"Definición operativa de regeneración:\n",
"- Existe quote call_center previa (Q0) para el par cliente+vehículo.\n",
"- Existe quote posterior (Q1...Qn) en un terminal de centro NO call_center, dentro de ventana D días.\n",
"- Q1 puede tener distinto `order_id` que Q0 (regenera de cero) o mismo (reescribe — menos común).\n",
"\n",
"Métricas pedidas:\n",
"1. Centros que MÁS regeneran (cuentan regeneraciones absolutas y % sobre quotes call_center recibidos).\n",
"2. Quotes call_center con regeneración vs sin regeneración."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5626c2cd",
"metadata": {},
"outputs": [],
"source": [
"from google.cloud import bigquery\n",
"import pandas as pd\n",
"\n",
"PROJECT = \"autingo-159109\"\n",
"DATASET = \"psql_dcpublic\"\n",
"bq = bigquery.Client(project=PROJECT)\n",
"\n",
"WINDOW_DAYS = 90 # ventana de análisis sobre quote call_center\n",
"REGEN_WINDOW_DAYS = 60 # ventana para detectar regeneración posterior\n",
"\n",
"def q(sql):\n",
" return bq.query(sql).to_dataframe()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "17cff6ce",
"metadata": {},
"outputs": [],
"source": [
"SQL_REGEN = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"-- Q0: quotes generados por call_center\n",
"q0 AS (\n",
" SELECT\n",
" q.id AS q0_id,\n",
" q.order_id AS q0_order,\n",
" q.created_at AS q0_ts,\n",
" o.customer_id,\n",
" o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start\n",
" AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL\n",
" AND o.vehicle_id IS NOT NULL\n",
"),\n",
"-- Q1..Qn: quotes posteriores para mismo cliente+vehículo, en centro NO call_center\n",
"qN AS (\n",
" SELECT\n",
" q.id AS qn_id,\n",
" q.order_id AS qn_order,\n",
" q.created_at AS qn_ts,\n",
" q.created_by_id,\n",
" o.customer_id,\n",
" o.vehicle_id,\n",
" o.terminal_id,\n",
" t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL\n",
" AND t.center_id NOT IN (159, 162)\n",
"),\n",
"-- Empareja Q0 con Q1+ dentro de regen_win días\n",
"regen AS (\n",
" SELECT\n",
" q0.q0_id,\n",
" q0.q0_order,\n",
" q0.customer_id,\n",
" q0.vehicle_id,\n",
" qN.qn_id,\n",
" qN.qn_order,\n",
" qN.center_id AS regen_center,\n",
" TIMESTAMP_DIFF(qN.qn_ts, q0.q0_ts, HOUR) / 24 AS dias_entre\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"),\n",
"-- Para cada Q0, ¿hay al menos UNA regeneración?\n",
"q0_has_regen AS (\n",
" SELECT q0_id, COUNT(*) AS regen_count,\n",
" MIN(dias_entre) AS dias_a_regen,\n",
" APPROX_TOP_COUNT(regen_center, 1)[OFFSET(0)].value AS first_regen_center\n",
" FROM regen\n",
" GROUP BY q0_id\n",
")\n",
"\n",
"-- Vista por centro: cuántos Q0 regenera cada centro\n",
"SELECT\n",
" c.id AS center_id,\n",
" c.name AS center_name,\n",
" COUNT(DISTINCT r.q0_id) AS q0_regenerados_aqui,\n",
" COUNT(*) AS regen_events,\n",
" ROUND(AVG(r.dias_entre), 1) AS dias_avg_regen\n",
"FROM regen r\n",
"JOIN `{PROJECT}.{DATASET}.centers` c ON r.regen_center = c.id\n",
"GROUP BY c.id, c.name\n",
"ORDER BY q0_regenerados_aqui DESC\n",
"LIMIT 30\n",
"\"\"\"\n",
"\n",
"df_centros = q(SQL_REGEN)\n",
"print(f\"Centros con eventos de regeneración: {len(df_centros)}\")\n",
"df_centros.head(30)"
]
},
{
"cell_type": "markdown",
"id": "43add847",
"metadata": {},
"source": [
"## Totales: Q0 con regeneración vs sin regeneración"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "736158ba",
"metadata": {},
"outputs": [],
"source": [
"SQL_TOT = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
"),\n",
"regen AS (\n",
" SELECT DISTINCT q0.q0_id\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
")\n",
"SELECT\n",
" COUNT(*) AS q0_total,\n",
" COUNT(DISTINCT r.q0_id) AS q0_regenerados,\n",
" COUNT(*) - COUNT(DISTINCT r.q0_id) AS q0_no_regenerados,\n",
" ROUND(SAFE_DIVIDE(COUNT(DISTINCT r.q0_id), COUNT(*)), 4) AS pct_regenerados\n",
"FROM q0\n",
"LEFT JOIN regen r USING (q0_id)\n",
"\"\"\"\n",
"q(SQL_TOT)"
]
},
{
"cell_type": "markdown",
"id": "c183a653",
"metadata": {},
"source": [
"## Distribución días hasta regeneración"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "aab452ca",
"metadata": {},
"outputs": [],
"source": [
"SQL_DIAS = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
")\n",
"SELECT\n",
" TIMESTAMP_DIFF(qN.qn_ts, q0.q0_ts, HOUR)/24 AS dias_entre\n",
"FROM q0\n",
"JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"\"\"\"\n",
"dias = q(SQL_DIAS)\n",
"print(dias.describe())\n",
"import matplotlib.pyplot as plt\n",
"dias[\"dias_entre\"].clip(upper=60).hist(bins=30)\n",
"plt.xlabel(\"Días entre Q0 (call_center) y Q1 (centro)\")\n",
"plt.ylabel(\"# eventos\")\n",
"plt.title(\"Distribución de regeneración temporal\")\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "0feaa9c1",
"metadata": {},
"source": [
"## Cruzar regeneración con conversión a factura\n",
"\n",
"¿Los Q0 regenerados convierten MENOS que los Q0 no regenerados? (Hipótesis: el cliente prefiere lo que negocia el centro)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "376502d8",
"metadata": {},
"outputs": [],
"source": [
"SQL_CONV = f\"\"\"\n",
"DECLARE win INT64 DEFAULT {WINDOW_DAYS};\n",
"DECLARE regen_win INT64 DEFAULT {REGEN_WINDOW_DAYS};\n",
"DECLARE t_start TIMESTAMP DEFAULT TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL win DAY);\n",
"\n",
"WITH\n",
"cc_users AS (\n",
" SELECT DISTINCT tpvuser_id AS user_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_authorization_tpvuser_centers`\n",
" WHERE dccenter_id IN (159, 162)\n",
"),\n",
"q0 AS (\n",
" SELECT q.id AS q0_id, q.order_id AS q0_order, q.created_at AS q0_ts,\n",
" o.customer_id, o.vehicle_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN cc_users cc ON q.created_by_id = cc.user_id\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" WHERE q.created_at >= t_start AND q.deleted_at IS NULL\n",
" AND o.customer_id IS NOT NULL AND o.vehicle_id IS NOT NULL\n",
"),\n",
"qN AS (\n",
" SELECT q.order_id AS qn_order, q.created_at AS qn_ts,\n",
" o.customer_id, o.vehicle_id, t.center_id\n",
" FROM `{PROJECT}.{DATASET}.tpv_orders_quote` q\n",
" JOIN `{PROJECT}.{DATASET}.tpv_orders_order` o ON q.order_id = o.id\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_terminals` t ON o.terminal_id = t.id\n",
" WHERE q.deleted_at IS NULL\n",
" AND t.center_id IS NOT NULL AND t.center_id NOT IN (159,162)\n",
"),\n",
"regen AS (\n",
" SELECT DISTINCT q0.q0_id\n",
" FROM q0\n",
" JOIN qN\n",
" ON q0.customer_id = qN.customer_id\n",
" AND q0.vehicle_id = qN.vehicle_id\n",
" AND qN.qn_ts > q0.q0_ts\n",
" AND qN.qn_ts <= TIMESTAMP_ADD(q0.q0_ts, INTERVAL regen_win DAY)\n",
" AND qN.qn_order != q0.q0_order\n",
"),\n",
"q0_inv AS (\n",
" SELECT q0.q0_id,\n",
" CASE WHEN i.id IS NOT NULL THEN 1 ELSE 0 END AS q0_factura\n",
" FROM q0\n",
" LEFT JOIN `{PROJECT}.{DATASET}.tpv_orders_invoice` i ON i.order_id = q0.q0_order\n",
")\n",
"SELECT\n",
" CASE WHEN r.q0_id IS NOT NULL THEN 'regenerado' ELSE 'no_regenerado' END AS bucket,\n",
" COUNT(*) AS q0_total,\n",
" SUM(qi.q0_factura) AS q0_convertido_propio,\n",
" ROUND(SAFE_DIVIDE(SUM(qi.q0_factura), COUNT(*)), 4) AS conv_q0_propio\n",
"FROM q0_inv qi\n",
"LEFT JOIN regen r USING (q0_id)\n",
"GROUP BY bucket\n",
"\"\"\"\n",
"q(SQL_CONV)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
}
},
"nbformat": 4,
"nbformat_minor": 5
}