Capítulo 14 de 15 10 secciones 21 min

Proyecto final: el encargo entero

"Mira los datos de la tienda y dime cómo vamos", contestado de punta a punta con todo lo del libro.

Un encargo real no llega en forma de consulta: llega como "dime cómo vamos". Este capítulo lo contesta en siete pasos sobre tienda.db: medir el tamaño y el periodo, auditar lo que está roto, mirar la tendencia, ver de dónde viene la venta, medir la concentración de clientes, sacar la lista de los que se están yendo, y resumirlo todo en seis frases que alguien pueda usar para decidir.

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

PostgreSQLCURRENT_DATE - MAX(fecha) -- con columnas DATE la resta da días
MySQLDATEDIFF(CURDATE(), MAX(fecha))
SQL ServerDATEDIFF(day, MAX(fecha), CAST(GETDATE() AS DATE))
SQLiteCAST(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! 🌸

¿Tienes alguna duda o consulta?