Trece capítulos de piezas sueltas. Este es el capítulo donde se juntan.
Te voy a dar el encargo tal como llega en la vida real, que nunca llega en forma de consulta: "Yera, mira los datos de la tienda y dime cómo vamos". Nada más. Sin métricas, sin preguntas, sin qué es "bien".
Lo que sigue es cómo trabajo yo un encargo así, en cinco pasos, y las consultas que salen de cada uno 🐣
Paso 1. Antes de nada, cuánto hay y de cuándo
La primera consulta de cualquier base nueva siempre es la misma: qué tamaño tiene esto y qué periodo cubre. Sin eso no puedes ni saber si un número es grande.
SELECT COUNT(*) AS pedidos,
COUNT(monto) AS con_monto,
COUNT(DISTINCT id_cliente) AS clientes,
MIN(fecha) AS desde, MAX(fecha) AS hasta,
ROUND(SUM(monto), 2) AS soles
FROM pedidos;
pedidos con_monto clientes desde hasta soles ------- --------- -------- ---------- ---------- --------- 900 873 119 2025-01-01 2026-06-24 532653.85
Ya con esa fila puedo escribir la primera línea del informe: 900 pedidos de 119 clientes, entre enero de 2025 y junio de 2026, por S/532.654.
Y ya tengo la primera pregunta incómoda: 900 pedidos pero solo 873 con monto. Eso no se menciona al final, se menciona ahora.
Paso 2. Qué está roto
Este paso se lo salta casi todo el mundo y es el que te salva de entregar un informe equivocado. Antes de analizar, audita.
SELECT 'pedidos sin cliente' AS problema, COUNT(*) AS filas FROM pedidos WHERE id_cliente IS NULL
UNION ALL
SELECT 'pedidos sin monto', COUNT(*) FROM pedidos WHERE monto IS NULL
UNION ALL
SELECT 'clientes con nombre sucio', COUNT(*) FROM clientes WHERE nombre <> TRIM(nombre)
UNION ALL
SELECT 'clientes que nunca compraron', COUNT(*) FROM clientes c
WHERE NOT EXISTS (SELECT 1 FROM pedidos p WHERE p.id_cliente = c.id)
UNION ALL
SELECT 'pedidos sin lineas de detalle', COUNT(*) FROM pedidos p
WHERE NOT EXISTS (SELECT 1 FROM detalle d WHERE d.id_pedido = p.id);
problema filas ----------------------------- ----- pedidos sin cliente 24 pedidos sin monto 27 clientes con nombre sucio 18 clientes que nunca compraron 1 pedidos sin lineas de detalle 0
Esa tabla es el capítulo 8 (NOT EXISTS) y el 5
(TRIM) trabajando juntos, y es lo primero que yo pego en un
correo.
Lo que dice, traducido a español de gerente:
- 🕳️ 24 pedidos no tienen cliente, así que cualquier reporte por cliente pierde 24 ventas. Alguien tiene que decidir si se recuperan o se descartan.
- 💸 27 pedidos no tienen monto. No valen cero: no se sabe cuánto valen. La venta total real es mayor que la que voy a reportar.
- 🧼 18 clientes tienen el nombre sucio, así que agrupar por nombre da resultados partidos.
- 👤 1 cliente no compró nunca. Ese está bien, es información, no error.
- ✅ 0 pedidos sin detalle. Ese cero también se reporta: comprobado que esa parte está sana.
Y ahora la decisión que hay que tomar en voz alta: voy a analizar los 873 pedidos con monto y voy a decirlo en cada tabla. Lo que no se puede hacer es taparlo, ni contar los nulos como cero 💛
Paso 3. Cómo va el negocio en el tiempo
WITH mes AS (
SELECT STRFTIME('%Y-%m', fecha) AS mes,
COUNT(*) AS pedidos,
ROUND(SUM(monto), 2) AS soles
FROM pedidos
GROUP BY mes
)
SELECT mes, pedidos, soles,
ROUND(soles - LAG(soles) OVER (ORDER BY mes), 2) AS variacion,
ROUND(AVG(soles) OVER (ORDER BY mes ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 2) AS media_3
FROM mes
ORDER BY mes;
mes pedidos soles variacion media_3 ------- ------- -------- --------- -------- 2025-01 64 36743.94 36743.94 2025-02 34 20231.23 -16512.71 28487.58 2025-03 57 36274.87 16043.64 31083.35 2025-04 55 35680.53 -594.34 30728.88 2025-05 45 28200.36 -7480.17 33385.25 2025-06 48 25942.48 -2257.88 29941.12 2025-07 60 34269.05 8326.57 29470.63 2025-08 57 34602.22 333.17 31604.58 2025-09 46 26239.3 -8362.92 31703.52 2025-10 52 34725.49 8486.19 31855.67 2025-11 47 27655.38 -7070.11 29540.06 2025-12 43 23921.77 -3733.61 28767.55 2026-01 51 29373.17 5451.4 26983.44 2026-02 40 24033.91 -5339.26 25776.28 2026-03 48 28103.64 4069.73 27170.24 2026-04 49 27651.42 -452.22 26596.32 2026-05 54 33711.97 6060.55 29822.34 2026-06 50 25293.12 -8418.85 28885.5
Dieciocho meses en una consulta que usa el capítulo 5
(STRFTIME), el 6 (GROUP BY), el 8
(WITH) y el 9 (LAG y la media móvil).
Y aquí está lo que yo le diría al cliente, que no es "subió" ni "bajó":
No hay tendencia. Mira la columna media_3,
saltándote la primera fila que es enero él solito: en 2025 va entre 28 y 33 mil, y
en 2026 entre 25 y 30 mil. La venta se mueve arriba y abajo mes a mes (febrero de
2025 cayó 16 mil, marzo subió 16 mil) pero el nivel es el mismo. Un negocio
plano.
Y ese último mes, junio de 2026 con una caída de 8.418, es el mes en curso: la base termina el 24 de junio. El último punto de una serie casi siempre está incompleto y casi siempre alguien lo lee como una caída. Decirlo es parte del trabajo 🚩
Paso 4. De dónde viene la venta
SELECT c.ciudad,
COUNT(DISTINCT c.id) AS clientes,
COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles,
ROUND(AVG(p.monto), 2) AS ticket,
ROUND(SUM(p.monto) / COUNT(DISTINCT c.id), 2) AS soles_por_cliente
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.ciudad
ORDER BY soles DESC;
ciudad clientes pedidos soles ticket soles_por_cliente -------- -------- ------- --------- ------ ----------------- Chiclayo 25 212 115979.43 568.53 4639.18 Piura 24 167 107165.17 657.46 4465.22 Arequipa 22 168 101432.51 637.94 4610.57 Cusco 17 112 64966.23 590.6 3821.54 Trujillo 17 113 64150.66 572.77 3773.57 Lima 15 104 63748.53 631.17 4249.9
Tres columnas que dicen tres cosas distintas y que la gente mezcla siempre:
- 💰 soles: cuánto pesa la ciudad. Chiclayo manda.
- 🎟️ ticket: cuánto vale un pedido. Aquí manda Piura con 657, y Chiclayo es la última con 568.
- 👤 soles_por_cliente: cuánto deja cada cliente al año. Chiclayo vuelve a subir.
- 🏙️ Y Lima, la ciudad grande, es la que menos clientes tiene de las seis, con 15. Esta tienda no es limeña.
La lectura útil: Chiclayo vende más porque tiene más clientes, no porque compre mejor. Si el encargo fuera "queremos crecer", en Chiclayo la palanca es subir el ticket y en Piura es conseguir más clientes. Dos ciudades, dos planes distintos, y eso sale de mirar tres columnas en vez de una 💡
SELECT canal,
COUNT(*) AS pedidos,
ROUND(AVG(monto), 2) AS ticket,
ROUND(SUM(monto), 2) AS soles,
COUNT(DISTINCT id_cliente) AS clientes,
ROUND(1.0 * COUNT(*) / COUNT(DISTINCT id_cliente), 2) AS pedidos_por_cliente
FROM pedidos
GROUP BY canal
ORDER BY soles DESC;
canal pedidos ticket soles clientes pedidos_por_cliente ----------- ------- ------ --------- -------- ------------------- Web 234 615.99 140446.17 105 2.23 WhatsApp 231 613.19 137968.56 97 2.38 Marketplace 229 611.5 135754.05 101 2.27 Tienda 206 598.41 118485.07 95 2.17
Los cuatro canales están empatados: entre 206 y 234 pedidos, entre 598 y 616 de ticket, entre 2,17 y 2,38 pedidos por cliente. La diferencia entre el primero y el último es del 18% en soles y nada en todo lo demás.
Eso es un hallazgo, aunque no lo parezca: el canal no explica nada. Si alguien esperaba que el reporte dijera "hay que apostar por Web", la respuesta honesta es que los datos no dan para eso.
Y cruzando con el capítulo 6: los clientes usan varios canales (89 de 119 compran por Web y por WhatsApp), así que "el canal" ni siquiera es una característica del cliente. Es lo que tuvo a mano ese día 📱
Paso 5. Quiénes son los clientes que importan
WITH por_cliente AS (
SELECT c.id, TRIM(c.nombre) AS nombre, c.ciudad, c.segmento,
COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles,
MAX(p.fecha) AS ultima_compra
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.ciudad, c.segmento
),
con_rango AS (
SELECT *, NTILE(10) OVER (ORDER BY soles DESC) AS decil
FROM por_cliente
WHERE soles IS NOT NULL
)
SELECT decil, COUNT(*) AS clientes, ROUND(SUM(soles), 2) AS soles,
ROUND(100.0 * SUM(soles) / SUM(SUM(soles)) OVER (), 1) AS porcentaje
FROM con_rango
GROUP BY decil
ORDER BY decil;
decil clientes soles porcentaje ----- -------- -------- ---------- 1 12 96744.12 18.7 2 12 78771.2 15.2 3 12 63481.77 12.3 4 12 58183.15 11.2 5 12 53769.42 10.4 6 12 48362.98 9.3 7 12 40954.78 7.9 8 12 33744.77 6.5 9 12 28002.44 5.4 10 11 15427.9 3.0
El primer decil, o sea los 12 mejores clientes, se lleva el 18,7% de la venta. El último decil, el 3%.
Y ahora una cosa importante y que va a contramano de lo que se espera: esto NO es un 80/20. Los tres primeros deciles, o sea el 30% de los clientes, hacen el 46% de la venta. Está concentrado, pero suavemente.
Yo he visto muchas presentaciones donde alguien fuerza el 80/20 porque queda bien en la diapositiva. Si tus datos dicen 46/30, lo que va en la diapositiva es 46/30. La gracia de medir es poder decir cuando algo no pasa 🌟
Paso 6. Quién se está yendo
Esta es la pregunta que nadie pide y que siempre agradecen, porque es la única del informe que se puede accionar mañana.
WITH por_cliente AS (
SELECT c.id, TRIM(c.nombre) AS nombre, c.ciudad,
COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles,
MAX(p.fecha) AS ultima_compra,
CAST(JULIANDAY('2026-06-24') - JULIANDAY(MAX(p.fecha)) AS INTEGER) AS dias_sin_comprar
FROM clientes c
JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.ciudad
)
SELECT CASE
WHEN dias_sin_comprar <= 30 THEN '1. activo (30 dias)'
WHEN dias_sin_comprar <= 90 THEN '2. tibio (31 a 90)'
WHEN dias_sin_comprar <= 180 THEN '3. frio (91 a 180)'
ELSE '4. perdido (mas de 180)'
END AS estado,
COUNT(*) AS clientes,
ROUND(SUM(soles), 2) AS soles_historicos
FROM por_cliente
GROUP BY estado
ORDER BY estado;
estado clientes soles_historicos ----------------------- -------- ---------------- 1. activo (30 dias) 48 231691.97 2. tibio (31 a 90) 40 192675.3 3. frio (91 a 180) 21 67980.7 4. perdido (mas de 180) 10 25094.56
119 clientes repartidos en cuatro cajones. 48 activos, 40 tibios, 21 fríos y 10 perdidos.
Los cortes (30, 90, 180) los elegí yo y no salen de ningún sitio: son una decisión de negocio, no un cálculo. En un encargo de verdad esa es una pregunta para el cliente, y mientras la contesta pones los tuyos y lo dices. Lo que no vale es presentarlos como si fueran una verdad de la base 🚩
Y ahora la tabla que de verdad sirve: los que se están enfriando y encima gastan bien. Lo primero que sale escribirla es así, y no corre.
SELECT c.id, TRIM(c.nombre) AS nombre, ROUND(SUM(p.monto), 2) AS soles FROM clientes c JOIN pedidos p ON p.id_cliente = c.id WHERE MAX(p.fecha) < '2026-03-24' GROUP BY c.id, c.nombre;
OperationalError: misuse of aggregate: MAX()
Mismo error que el WHERE COUNT(*) del capítulo 6, y por el mismo
motivo: cuando el WHERE hace su trabajo, los grupos todavía no
existen, así que no hay ningún MAX que calcular.
La última compra de un cliente solo existe después de agrupar, así que hay que calcularla en un CTE y filtrar fuera, exactamente como con las ventanas del capítulo 9.
WITH por_cliente AS (
SELECT c.id, TRIM(c.nombre) AS nombre, c.ciudad,
ROUND(SUM(p.monto), 2) AS soles,
MAX(p.fecha) AS ultima_compra,
CAST(JULIANDAY('2026-06-24') - JULIANDAY(MAX(p.fecha)) AS INTEGER) AS dias
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.ciudad
)
SELECT nombre, ciudad, soles, ultima_compra, dias
FROM por_cliente
WHERE dias > 90 AND soles > 4000
ORDER BY soles DESC
LIMIT 8;
nombre ciudad soles ultima_compra dias --------------------- -------- ------- ------------- ---- Mayorista Peru 065 Piura 6023.77 2026-01-12 163 Cafe del Puerto 073 Chiclayo 4891.83 2026-03-20 96 Market Central 117 Piura 4853.62 2025-12-01 205 Distribuidora Paz 095 Arequipa 4828.49 2026-03-11 105 Minimarket El Sol 002 Piura 4675.13 2026-02-23 121 CAFE DEL PUERTO 013 Chiclayo 4518.11 2026-02-16 128 Distribuidora Paz 081 Piura 4347.77 2025-11-15 221 Minimarket El Sol 003 Piura 4217.49 2026-03-24 92
Esto no es un análisis, es una lista de llamadas. Ocho nombres, con su ciudad, cuánto dejaron y cuándo fue la última vez. Alguien puede coger el teléfono hoy.
Los días que lleva un cliente sin comprar
| PostgreSQL | CURRENT_DATE - MAX(fecha) -- con columnas DATE la resta da días |
| MySQL | DATEDIFF(CURDATE(), MAX(fecha)) |
| SQL Server | DATEDIFF(day, MAX(fecha), CAST(GETDATE() AS DATE)) |
| SQLite | CAST(JULIANDAY('now') - JULIANDAY(MAX(fecha)) AS INTEGER) |
Es la única línea de todo este informe que hay que traducir al cambiar de motor. El WITH, el JOIN, el GROUP BY y el CASE de arriba se escriben igual en los cuatro.
Fíjate que cinco de los ocho son de Piura, la ciudad del ticket más alto. Eso sí es una recomendación con nombre: empezar por Piura.
Y si te preguntas por qué no hay un modelo de machine learning aquí: porque para esta pregunta no hace falta. Una consulta de doce líneas contesta lo que alguien va a hacer mañana. Si después quieres predecir quién se va a ir antes de que se vaya, ahí sí, y esa es la guía de machine learning 🐣
Paso 7. Qué se vende
SELECT pr.categoria,
SUM(d.cantidad) AS unidades,
ROUND(SUM(d.cantidad * d.precio_unit), 2) AS soles,
ROUND(100.0 * SUM(d.cantidad * d.precio_unit) / SUM(SUM(d.cantidad * d.precio_unit)) OVER (), 1) AS pct
FROM detalle d
JOIN productos pr ON pr.id = d.id_producto
GROUP BY pr.categoria
ORDER BY soles DESC;
categoria unidades soles pct ---------------- -------- --------- ---- Limpieza 10076 361951.33 23.4 Snacks 6548 338311.89 21.9 Bebidas 5488 316973.5 20.5 Abarrotes 7454 310302.68 20.1 Cuidado personal 3601 218079.75 14.1
Otra vez plano: cuatro categorías entre el 20% y el 23%, y Cuidado personal un poco más abajo con 14%. Ninguna manda.
Acuérdate del aviso del capítulo 7: en esta base el monto del pedido y la suma de sus líneas no cuadran, así que estos soles son "soles de línea de detalle" y no se pueden sumar con los del paso 3. En un informe eso va escrito al pie de la tabla, no en la cabeza de quien la hizo 📝
WITH ranking AS (
SELECT pr.categoria, pr.nombre,
ROUND(SUM(d.cantidad * d.precio_unit), 2) AS soles,
ROW_NUMBER() OVER (PARTITION BY pr.categoria ORDER BY SUM(d.cantidad * d.precio_unit) DESC) AS puesto
FROM detalle d JOIN productos pr ON pr.id = d.id_producto
GROUP BY pr.categoria, pr.id, pr.nombre
)
SELECT categoria, nombre, soles FROM ranking WHERE puesto = 1 ORDER BY soles DESC;
categoria nombre soles ---------------- ----------- -------- Cuidado personal Producto 16 71490.94 Abarrotes Producto 17 67500.16 Snacks Producto 36 66044.12 Bebidas Producto 37 59064.6 Limpieza Producto 31 58028.3
El más vendido de cada categoría, con el patrón del capítulo 9 que ya conoces
de memoria: CTE, ROW_NUMBER con PARTITION BY, filtrar
por el puesto fuera.
El informe, en seis líneas
Todo lo de arriba se resume en esto, y esto es lo que se lee:
- 📊 900 pedidos de 119 clientes entre enero de 2025 y junio de 2026, por S/532.654. 27 pedidos no tienen monto, así que la venta real es mayor.
- ➖ El negocio está plano. Dieciocho meses sin tendencia, entre 25 y 33 mil al mes. Junio de 2026 está a medias, no es una caída.
- 🏙️ Chiclayo vende más por volumen y Piura por ticket. Son dos planes de crecimiento distintos.
- 📱 El canal no explica nada: los cuatro están empatados y la gente usa varios.
- 👥 La concentración es suave: el 30% de los clientes hace el 46%. No es un 80/20.
- 📞 31 clientes llevan más de 90 días sin comprar, y ocho de ellos gastaron más de S/4.000. Esa lista va adjunta y es lo único de este informe que se puede accionar mañana.
Fíjate en algo: ninguna de las seis dice una consulta. El SQL es cómo llegaste, no es lo que entregas. Lo que entregas son frases que alguien puede usar para decidir 💛
Ejercicios
Siete, y son el encargo entero otra vez con preguntas nuevas. Intenta antes de abrir 💛
1. La cohorte de alta
Por mes de alta del cliente: cuántos entraron, cuántos pedidos hicieron y cuántos por cliente.
WITH alta AS (
SELECT id, STRFTIME('%Y-%m', fecha_alta) AS cohorte FROM clientes
),
compras AS (
SELECT a.cohorte, COUNT(DISTINCT a.id) AS clientes, COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles
FROM alta a LEFT JOIN pedidos p ON p.id_cliente = a.id
GROUP BY a.cohorte
)
SELECT cohorte, clientes, pedidos, soles,
ROUND(1.0 * pedidos / clientes, 1) AS pedidos_por_cliente
FROM compras
ORDER BY cohorte
LIMIT 8;
cohorte clientes pedidos soles pedidos_por_cliente ------- -------- ------- -------- ------------------- 2025-01 14 98 58864.52 7.0 2025-02 8 47 26168.11 5.9 2025-03 10 73 45658.55 7.3 2025-04 10 81 47303.73 8.1 2025-05 14 98 57792.24 7.0 2025-06 5 44 30132.23 8.8 2025-07 7 54 35022.99 7.7 2025-08 11 69 40323.32 6.3
Ojo con leer esta tabla: los de enero de 2025 llevan 18 meses en la casa y los de agosto llevan 10, así que tener más pedidos no significa ser mejores clientes. Para comparar cohortes de verdad hay que mirar los primeros N meses de cada una, no el total.
Es el error más frecuente que veo en análisis de cohortes, y se cuela porque la tabla se ve bien 🫠
2. Cuántos compraron una sola vez
De los que compraron, cuántos lo hicieron una única vez.
SELECT COUNT(*) AS clientes,
SUM(CASE WHEN pedidos = 1 THEN 1 ELSE 0 END) AS compraron_una_vez,
ROUND(100.0 * SUM(CASE WHEN pedidos = 1 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct
FROM (SELECT id_cliente, COUNT(*) AS pedidos FROM pedidos WHERE id_cliente IS NOT NULL GROUP BY id_cliente);
clientes compraron_una_vez pct -------- ----------------- --- 119 1 0.8
Uno de 119, o sea el 0,8%. En una tienda de verdad ese número suele estar entre
el 40% y el 60%, así que aquí no lo está, y hay que decirlo:
tienda.db es una base de práctica y sus clientes compran mucho más
seguido de lo que compraría gente de verdad.
Cuando un indicador te sale rarísimo comparado con lo que sabes del mundo, casi nunca es un hallazgo. Casi siempre es cómo se generaron o se cargaron los datos 🕵️♀️
3. El mejor mes de cada año
Con ventanas: el mes de más venta de 2025 y el de 2026.
WITH mes AS (
SELECT STRFTIME('%Y', fecha) AS anio, STRFTIME('%Y-%m', fecha) AS mes,
ROUND(SUM(monto), 2) AS soles
FROM pedidos GROUP BY anio, mes
),
ranking AS (
SELECT anio, mes, soles, ROW_NUMBER() OVER (PARTITION BY anio ORDER BY soles DESC) AS puesto
FROM mes
)
SELECT anio, mes, soles FROM ranking WHERE puesto = 1 ORDER BY anio;
anio mes soles ---- ------- -------- 2025 2025-01 36743.94 2026 2026-05 33711.97
Y con el aviso de siempre: 2026 solo tiene seis meses, así que su "mejor mes" compite contra la mitad de rivales que el de 2025.
4. Ciudad y segmento a la vez
Cuántos soles deja cada combinación de ciudad y segmento, y cuánto pesa dentro de su ciudad.
SELECT c.ciudad, c.segmento,
COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles,
ROUND(100.0 * SUM(p.monto) / SUM(SUM(p.monto)) OVER (PARTITION BY c.ciudad), 1) AS pct_ciudad
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
WHERE c.ciudad IN ('Lima', 'Cusco')
GROUP BY c.ciudad, c.segmento
ORDER BY c.ciudad, soles DESC;
ciudad segmento pedidos soles pct_ciudad ------ ---------- ------- -------- ---------- Cusco Mayorista 38 20750.96 31.9 Cusco Bodega 37 19568.19 30.1 Cusco Minimarket 18 12905.05 19.9 Cusco Horeca 19 11742.03 18.1 Lima Horeca 39 23957.55 37.6 Lima Mayorista 37 20900.86 32.8 Lima Bodega 17 11351.72 17.8 Lima Minimarket 11 7538.4 11.8
El SUM(SUM(...)) OVER (PARTITION BY ...) del capítulo 9, que
sigue pareciendo un error de escritura y sigue siendo correcto: primero agrupa,
después suma los grupos de cada ciudad.
5. La vista que entregas con el informe
Deja una vista con la ficha de cada cliente, para que quien la pida no tenga que escribir nada de esto.
CREATE VIEW ficha_cliente AS
SELECT c.id,
UPPER(TRIM(c.nombre)) AS nombre,
c.ciudad, c.segmento, c.fecha_alta,
COUNT(p.id) AS pedidos,
ROUND(SUM(p.monto), 2) AS soles,
MAX(p.fecha) AS ultima_compra,
CAST(JULIANDAY('2026-06-24') - JULIANDAY(MAX(p.fecha)) AS INTEGER) AS dias_sin_comprar
FROM clientes c
LEFT JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.ciudad, c.segmento, c.fecha_alta;
SELECT nombre, ciudad, pedidos, soles, dias_sin_comprar
FROM ficha_cliente
ORDER BY soles DESC
LIMIT 5;
nombre ciudad pedidos soles dias_sin_comprar -------------------------- -------- ------- -------- ---------------- DISTRIBUIDORA PAZ 058 Lima 17 10131.72 23 ALMACENES VEGA 067 Piura 12 9516.06 22 MARKET CENTRAL 087 Chiclayo 12 8148.56 53 RESTAURANTE MIRAFLORES 093 Trujillo 12 8119.95 48 MARKET CENTRAL 078 Chiclayo 14 7977.9 62
Eso es el capítulo 13 cerrando el círculo: la limpieza del 5, el
LEFT JOIN del 7, la fecha del 5 y el GROUP BY del 6,
todo guardado con un nombre que cualquiera puede consultar.
Y una advertencia práctica: esa fecha '2026-06-24' está escrita a
mano porque este libro necesita que a ti te salga lo mismo que a mí. En tu vista
de verdad va DATE('now'), y así se recalcula sola cada día.
6. El top de cada ciudad, desde la vista
El mejor cliente de cada ciudad, usando la vista del ejercicio anterior.
WITH ranking AS (
SELECT ciudad, nombre, soles,
ROW_NUMBER() OVER (PARTITION BY ciudad ORDER BY soles DESC) AS puesto
FROM ficha_cliente
WHERE soles IS NOT NULL
)
SELECT ciudad, nombre, soles FROM ranking WHERE puesto = 1 ORDER BY soles DESC;
ciudad nombre soles -------- -------------------------- -------- Lima DISTRIBUIDORA PAZ 058 10131.72 Piura ALMACENES VEGA 067 9516.06 Chiclayo MARKET CENTRAL 087 8148.56 Trujillo RESTAURANTE MIRAFLORES 093 8119.95 Arequipa CAFE DEL PUERTO 060 7948.71 Cusco MAYORISTA PERU 027 7732.46
Ocho líneas, y hacen lo mismo que las veinte del capítulo 9. Eso es lo que gana una vista: la próxima pregunta sobre clientes ya empieza a mitad de camino 🌟
7. Escríbelo para los cuatro
Sin ejecutar: el informe de clientes en riesgo, listo para correr en cualquiera de los cuatro motores contra una base de verdad.
-- PostgreSQL
WITH por_cliente AS (
SELECT c.id, TRIM(c.nombre) AS nombre, c.ciudad,
SUM(p.monto) AS soles, MAX(p.fecha) AS ultima
FROM clientes c JOIN pedidos p ON p.id_cliente = c.id
GROUP BY c.id, c.nombre, c.ciudad
)
SELECT nombre, ciudad, ROUND(soles::numeric, 2) AS soles, ultima,
CURRENT_DATE - ultima AS dias
FROM por_cliente WHERE ultima < CURRENT_DATE - INTERVAL '90 days' AND soles > 4000
ORDER BY soles DESC;
-- MySQL: cambia la resta de fechas
DATEDIFF(CURDATE(), ultima) AS dias
FROM por_cliente WHERE ultima < DATE_SUB(CURDATE(), INTERVAL 90 DAY) AND soles > 4000
-- SQL Server: cambia el orden de los argumentos del DATEDIFF
DATEDIFF(day, ultima, CAST(GETDATE() AS DATE)) AS dias
FROM por_cliente WHERE ultima < DATEADD(day, -90, CAST(GETDATE() AS DATE)) AND soles > 4000
-- SQLite
CAST(JULIANDAY('now') - JULIANDAY(ultima) AS INTEGER) AS dias
FROM por_cliente WHERE ultima < DATE('now', '-90 days') AND soles > 4000
Y ahí lo tienes resumido: el esqueleto es idéntico en los
cuatro. El WITH, el JOIN, el
GROUP BY, el WHERE, el ORDER BY, todo
igual. Lo único que cambia son las fechas, que es exactamente lo que decía el
capítulo 5.
Si te llevas una sola cosa de las tablas de dialectos de todo el libro, que sea esta: lo que cambia entre motores casi nunca es cómo piensas la consulta, es cómo se escriben cuatro funciones. Y eso se busca en un minuto 🐣
Lo que te llevas
- 📏 Empieza siempre por cuánto hay y de cuándo. Sin eso no sabes si un número es grande.
- 🔍 Audita antes de analizar, y reporta los ceros comprobados igual que los problemas.
- 🗣️ Di en voz alta qué dejaste fuera y por qué. Aquí: 27 pedidos sin monto.
- ➖ "No hay tendencia" y "el canal no explica nada" son hallazgos. Poder decir que algo no pasa es la mitad del valor de medir.
- 🚩 El último punto de una serie casi siempre está incompleto.
- 📞 Termina con algo accionable. Una lista de ocho nombres vale más que veinte gráficos.
- 🧾 Lo que entregas son frases, no consultas. El SQL es cómo llegaste.
El capítulo 15 es el último y es cortito: dónde está la documentación oficial de cada motor, y qué leer después de este libro.
Que tengas lindo día! 🌸