Inscríbete para acceder a todas las lecciones de este curso.
Lección 5 de 16· Hito 2 — Las preguntas de negocio, respondidas en SQL
Esa es, palabra por palabra, el mensaje que te llega de la gerencia de Tienda Andina un lunes a las 8:03 a.m.
Es una pregunta imposible de responder tal como está escrita. ¿Ventas de qué: unidades, pedidos, dinero? ¿De todo el histórico o de este año? ¿Cuentan los pedidos cancelados? ¿Con descuento o sin descuento? Si escribes SQL antes de contestar esas preguntas, vas a producir un número que se ve profesional y que está mal — el peor resultado posible en un reporte.
En esta lección aprendes el método que usa cualquier analista para no caer en eso: traducir una pregunta vaga a una consulta concreta definiendo cuatro cosas — métrica, grano, filtro y periodo — y luego construir la consulta paso a paso, verificando el resultado en cada etapa. Al terminar tendrás la primera tabla real de tu reporte: el ingreso mensual de 2024, la columna sobre la que se apoyará todo el análisis en pandas de los siguientes hitos.
Recordatorio de contexto: trabajas sobre la base de Tienda Andina, con cuatro tablas —
customers,products,orders(una fila por pedido, constatusyorder_date) yorder_items(una fila por producto dentro de un pedido, conquantity,unit_priceydiscount). Cada bloque SQL de esta lección se ejecuta de verdad en tu navegador y comparte la misma base, así que puedes correrlos en orden.
Antes de teclear SELECT, escribe (literalmente, en un comentario) las cuatro decisiones:
| Decisión | Pregunta que responde | En nuestro caso |
|---|---|---|
| Métrica | ¿Qué número exactamente? | Ingreso = quantity * unit_price * (1 - discount), sumado |
| Grano | ¿Una fila por qué cosa? | Una fila por mes |
| Filtro | ¿Qué filas NO cuentan? | Solo pedidos con status = 'entregado' |
| Periodo | ¿Qué ventana de tiempo? | Año 2024 (order_date entre 2024-01-01 y 2024-12-31) |
Esa tabla de cuatro filas es tu contrato con quien pidió el número. Si el gerente dice "no, los devueltos también cuentan como venta porque ya facturamos", cambias una celda y la consulta se ajusta sola. Sin ese contrato, cada revisión es empezar de cero.
Y una regla práctica que vale oro: el grano manda sobre el GROUP BY, y el filtro manda sobre el WHERE. Si el grano es el mes, la consulta agrupa por mes. Si el filtro es el estado del pedido, ese filtro es un WHERE sobre filas — no un HAVING sobre grupos.
El error más común de todos empieza aquí: mirar unit_price y sumarlo. unit_price es el precio de una unidad, antes del descuento. El ingreso real de una línea de pedido es:
Antes de agregar nada, construye esa columna y míralas fila por fila. Si la métrica está mal aquí, todo lo demás hereda el error y ya no lo vas a ver, porque un total agregado no delata de dónde salió.
Ejecuta esto:
-- Paso 1: el ingreso de CADA línea de pedido (sin agrupar todavía)
SELECT
order_item_id,
order_id,
quantity,
unit_price,
discount,
ROUND(quantity * unit_price * (1 - discount), 2) AS ingreso_linea
FROM order_items
ORDER BY order_item_id
LIMIT 10;Verifica dos filas a mano — es literalmente el control de calidad de tu métrica:
order_id 1001): 2 termos × 69 000 × (1 − 0) = 138 000. Sin la cantidad habrías reportado 69 000.order_id 1002): 1 monitor × 749 000 × (1 − 0.05) = 711 550. Sin el descuento habrías reportado 749 000, es decir 37 450 pesos inventados en una sola línea.Uso ROUND(..., 2) a propósito: al multiplicar dos NUMERIC con dos decimales cada uno, PostgreSQL devuelve un resultado con cuatro decimales (149000.0000). No está mal, pero ensucia el reporte y complica comparar salidas. Redondear a dos decimales fija el formato desde el principio.
order_items sabe qué se vendió y por cuánto, pero no sabe cuándo ni si el pedido llegó a buen puerto: order_date y status están en orders. Como cada línea pertenece a exactamente un pedido, un INNER JOIN por order_id le pega esos dos atributos a cada línea sin duplicar ni perder filas.
Otra vez: primero mira las filas, después agrega.
-- Paso 2: traer fecha y estado del pedido a cada línea
SELECT
o.order_id,
o.order_date,
o.status,
oi.product_id,
oi.quantity,
ROUND(oi.quantity * oi.unit_price * (1 - oi.discount), 2) AS ingreso_linea
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
ORDER BY o.order_date, oi.order_item_id
LIMIT 12;Fíjate en algo importante para el reporte: la consulta devuelve 40 filas (una por línea de pedido), no 30 (una por pedido). Ese es el grano actual de tu resultado. Todavía no es el grano que quieres — pero saber en qué grano estás en cada momento es la diferencia entre un analista que confía en su número y uno que reza.
Antes de seguir, confirma que el JOIN no perdió ni multiplicó filas. Si COUNT(*) sobre el JOIN no coincide con COUNT(*) de order_items, tienes un problema de claves (un order_id huérfano o duplicado) y ninguna métrica posterior es confiable.
-- Control de sanidad: ¿el JOIN conservó exactamente las 40 líneas?
SELECT
(SELECT COUNT(*) FROM order_items) AS lineas_originales,
(SELECT COUNT(*) FROM order_items oi JOIN orders o ON o.order_id = oi.order_id) AS lineas_tras_join,
(SELECT COUNT(DISTINCT order_id) FROM order_items) AS pedidos_con_lineas;En orders conviven tres estados: entregado, cancelado y devuelto. Un pedido cancelado nunca se cobró; uno devuelto se cobró y se reversó. Ninguno de los dos es ingreso, y meterlos en el total es la forma más rápida de que tu reporte quede desmentido por el área de finanzas.
Antes de excluirlos, cuantifica cuánto pesan. Ese es un hallazgo por sí mismo y va a ir en tu reporte final.
-- Paso 3: ¿cuánto ingreso hay detrás de cada estado?
SELECT
o.status,
COUNT(DISTINCT o.order_id) AS pedidos,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS ingreso_bruto
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
GROUP BY o.status
ORDER BY ingreso_bruto DESC;El resultado es contundente: 27 pedidos entregados suman 10 362 650, mientras que los 2 cancelados y el 1 devuelto arrastran 2 187 000. Es decir, el 17,4 % del "total de ventas" no es venta. Un reporte que no filtra por estado exagera el ingreso en casi una quinta parte.
Dos detalles técnicos que valen para todo el proyecto:
COUNT(DISTINCT o.order_id) cuenta pedidos; COUNT(*) sobre este JOIN contaría líneas. Como un pedido puede tener varias líneas, confundirlos infla el número de pedidos y arruina cualquier "ticket promedio".WHERE. HAVING filtra grupos ya agregados y solo tiene sentido para condiciones sobre el agregado (p. ej. HAVING SUM(...) > 1000000). Si pones status = 'entregado' en HAVING, PostgreSQL te lo rechaza o, peor, te obliga a agrupar por status y el grano deja de ser el mes.Ahora sí: una fila por mes. Tienes dos formas idiomáticas de sacar el mes de una fecha en PostgreSQL, y conviene conocer las dos porque hacen cosas distintas:
| Expresión | Devuelve | Cuándo usarla |
|---|---|---|
date_trunc('month', order_date) | Un timestamp: 2024-01-01 00:00:00 | Cuando necesitas seguir haciendo aritmética de fechas u ordenar cronológicamente sin ambigüedad |
to_char(order_date, 'YYYY-MM') | Texto: '2024-01' | Cuando el resultado va a un reporte, un CSV o pandas: es corto, legible y ordena bien alfabéticamente |
Un error clásico es to_char(order_date, 'MM'), que devuelve '01' y mezcla enero de 2024 con enero de 2025 en el mismo grupo. Si tu grano es "mes calendario", el año tiene que estar en la clave de agrupación.
Juntemos las cuatro decisiones en una sola consulta:
-- Paso 4: ingreso mensual de pedidos entregados en 2024
-- métrica: SUM(qty * precio * (1-desc)) | grano: mes | filtro: entregado | periodo: 2024
SELECT
to_char(o.order_date, 'YYYY-MM') AS mes,
date_trunc('month', o.order_date)::date AS inicio_mes,
COUNT(DISTINCT o.order_id) AS pedidos,
ROUND(SUM(oi.quantity * oi.unit_price * (1 - oi.discount)), 2) AS ingreso
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
WHERE o.status = 'entregado'
AND o.order_date >= DATE '2024-01-01'
AND o.order_date < DATE '2025-01-01'
GROUP BY 1, 2
ORDER BY 2;Ocho filas, una por mes de enero a agosto de 2024. Esa tabla ya es material de reporte.
Sobre el filtro de periodo: usé >= '2024-01-01' AND < '2025-01-01' en vez de BETWEEN '2024-01-01' AND '2024-12-31'. Con columnas DATE los dos son equivalentes, pero en cuanto la columna sea TIMESTAMP el BETWEEN pierde silenciosamente todo lo que ocurrió el 31 de diciembre después de medianoche. Acostúmbrate al patrón mitad-abierto >= inicio AND < siguiente_inicio: funciona siempre.
Así se ve la serie que acabas de calcular:
Ojo con la lectura honesta: con 27 pedidos, un solo monitor de 749 000 mueve la aguja de un mes entero. Abril es el mejor mes casi por completo gracias a un pedido con silla y escritorio. Cuando el volumen es bajo, la "tendencia" es ruido — anótalo, porque es exactamente el tipo de matiz que separa un reporte de portafolio decente de uno excelente.
| Error | Cómo se ve en el SQL | Qué provoca |
|---|---|---|
| Sumar el precio sin la cantidad | SUM(unit_price) | Subestima: ignora que se vendieron 2, 3 o 4 unidades |
| Olvidar el descuento | SUM(quantity * unit_price) | Sobreestima: cobra de más en cada línea con descuento |
| No filtrar por estado (o filtrarlo tarde) | sin WHERE status = 'entregado', o el filtro en HAVING | Cuenta cancelados y devueltos: +17,4 % de ingreso fantasma |
| Agrupar por mes sin año | to_char(order_date, 'MM') | Mezcla el mismo mes de años distintos en un solo grupo |
Ejecuta esta versión defectuosa y compárala con la tabla correcta del paso 4:
-- ❌ Los tres primeros errores, juntos en una sola consulta
SELECT
to_char(o.order_date, 'YYYY-MM') AS mes,
ROUND(SUM(oi.unit_price), 2) AS ingreso_malo -- sin cantidad, sin descuento
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id -- sin filtro de estado
GROUP BY 1
ORDER BY 1;Enero devuelve 1 181 000 en lugar de 1 307 550: un 9,7 % por debajo. Y no es un error "pequeño y constante" que puedas corregir con un factor: en los meses con pedidos cancelados el sesgo cambia de dirección. Un número que se equivoca de forma inconsistente es peor que no tener número.
JOIN y comprueba que el conteo de filas no cambió.WHERE (filas), no en HAVING (grupos).Esta consulta es la primera pieza real de tu entregable — la vas a exportar y cargar en pandas en el Hito 3. Escríbela completa y compruébala.
Escribe la consulta que devuelve el ingreso mensual de 2024 considerando únicamente los pedidos con estado 'entregado'. El resultado debe tener exactamente dos columnas — mes (texto 'YYYY-MM') e ingreso (suma de quantity * unit_price * (1 - discount) redondeada a 2 decimales) — y una fila por mes, ordenadas cronológicamente.
-- Ingreso mensual de 2024, SOLO pedidos entregados.
-- Devuelve exactamente dos columnas, en este orden y con estos nombres:
-- mes -> texto con formato 'YYYY-MM'
-- ingreso -> SUM(quantity * unit_price * (1 - discount)) redondeado a 2 decimales
-- Ordena cronológicamente (de 2024-01 en adelante).
SELECT
-- TODO: el mes como texto 'YYYY-MM', con alias mes
-- TODO: el ingreso del mes redondeado a 2 decimales, con alias ingreso
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
-- TODO: filtra por estado entregado y por el año 2024
-- TODO: agrupa por mes y ordena cronológicamente
;Cuando el ✅ aparezca, guarda esa consulta: es la primera tabla del reporte de Tienda Andina. En la siguiente lección repetirás el mismo método —métrica, grano, filtro, periodo— pero cambiando el grano a producto, categoría y cliente, para responder qué se vende más y quién compra más.
Gratis