En este proyecto vamos a aplicar los principales conceptos de SQL y MySQL construyendo un pequeño sistema de ventas. La idea no es solamente copiar consultas, sino entender por qué existe cada tabla y cómo se relacionan.
El objetivo del proyecto
Queremos que al finalizar puedas mirar un requerimiento simple y convertirlo en tablas relacionadas, además de construir consultas de reporte sobre ellas.
Este proyecto integra lo visto anteriormente en lugar de presentar conceptos aislados.
¿Qué vamos a modelar?
El sistema manejará:
- clientes;
- categorías;
- productos;
- ventas;
- detalle de cada venta.
¿Por qué varias tablas?
Separar entidades evita duplicación. Un cliente puede tener muchas ventas, una categoría muchos productos y una venta muchos detalles.
Las relaciones principales son:
- cliente 1 → N ventas;
- categoría 1 → N productos;
- venta 1 → N detalles;
- producto 1 → N detalles.
El detalle actúa como puente entre la venta y los productos comprados.
Crear la base
CREATE DATABASE tienda; USE tienda;
Tabla clientes
Guarda la información propia del cliente. email es UNIQUE para evitar duplicados.
CREATE TABLE clientes (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
apellido VARCHAR(100) NOT NULL,
email VARCHAR(150) UNIQUE,
ciudad VARCHAR(100),
activo BOOLEAN DEFAULT TRUE,
fecha_alta DATETIME DEFAULT CURRENT_TIMESTAMP
);
Tabla categorias
Las categorías se separan para no repetir el nombre en cada producto.
CREATE TABLE categorias (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL UNIQUE
);
Tabla productos
categoria_id conecta el producto con su categoría.
CREATE TABLE productos ( id INT AUTO_INCREMENT PRIMARY KEY, nombre VARCHAR(150) NOT NULL, precio DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, categoria_id INT NOT NULL, activo BOOLEAN DEFAULT TRUE, FOREIGN KEY (categoria_id) REFERENCES categorias(id) );
Tabla ventas
Representa la cabecera de una operación. Guarda cliente, fecha y total.
CREATE TABLE ventas ( id INT AUTO_INCREMENT PRIMARY KEY, cliente_id INT NOT NULL, fecha DATETIME DEFAULT CURRENT_TIMESTAMP, total DECIMAL(12,2) NOT NULL DEFAULT 0, FOREIGN KEY (cliente_id) REFERENCES clientes(id) );
Cabecera y detalle
Este patrón aparece en facturas, pedidos, presupuestos y remitos. La cabecera representa la operación general; el detalle representa sus ítems.
Una venta puede tener diez productos sin repetir diez veces la fecha, el cliente y otros datos generales.
Tabla ventas_detalles
Cada fila representa un producto dentro de una venta. Guardamos el precio unitario de ese momento para conservar el valor histórico.
CREATE TABLE ventas_detalles ( id INT AUTO_INCREMENT PRIMARY KEY, venta_id INT NOT NULL, producto_id INT NOT NULL, cantidad INT NOT NULL, precio_unitario DECIMAL(10,2) NOT NULL, subtotal DECIMAL(12,2) NOT NULL, FOREIGN KEY (venta_id) REFERENCES ventas(id) ON DELETE CASCADE, FOREIGN KEY (producto_id) REFERENCES productos(id) );
Registrar una venta con transacción
Venta, detalle y stock deben mantenerse consistentes. Por eso usamos una transacción.
Si se crea la venta pero falla el descuento de stock, no queremos dejar una operación parcialmente registrada.
START TRANSACTION; INSERT INTO ventas (cliente_id, total) VALUES (1, 0); SET @venta_id = LAST_INSERT_ID(); INSERT INTO ventas_detalles (venta_id, producto_id, cantidad, precio_unitario, subtotal) VALUES (@venta_id, 3, 2, 95000, 190000), (@venta_id, 4, 1, 45000, 45000); UPDATE productos SET stock = stock - 2 WHERE id = 3; UPDATE productos SET stock = stock - 1 WHERE id = 4; UPDATE ventas SET total = 235000 WHERE id = @venta_id; COMMIT;
¿Qué hace LAST_INSERT_ID()?
Devuelve el último identificador AUTO_INCREMENT generado en esa conexión. Lo usamos para saber qué venta acabamos de crear.
Es importante porque los detalles necesitan conocer el id de la cabecera recién insertada.
De guardar datos a obtener información
El diseño de tablas es solo la primera parte. El verdadero valor aparece cuando combinamos esas tablas para responder preguntas del negocio.
Consultar ventas con clientes
SELECT v.id, v.fecha, c.nombre, c.apellido, v.total FROM ventas AS v INNER JOIN clientes AS c ON v.cliente_id = c.id ORDER BY v.fecha DESC;
JOIN permite reemplazar el cliente_id por información legible del cliente.
Detalle completo de una venta
SELECT v.id AS venta, c.nombre AS cliente, p.nombre AS producto, vd.cantidad, vd.precio_unitario, vd.subtotal FROM ventas AS v INNER JOIN clientes AS c ON v.cliente_id = c.id INNER JOIN ventas_detalles AS vd ON vd.venta_id = v.id INNER JOIN productos AS p ON vd.producto_id = p.id;
Total comprado por cliente
Agrupamos por cliente y sumamos sus ventas.
SELECT c.id, c.nombre, c.apellido, SUM(v.total) AS total_comprado FROM clientes AS c INNER JOIN ventas AS v ON v.cliente_id = c.id GROUP BY c.id, c.nombre, c.apellido ORDER BY total_comprado DESC;
Productos más vendidos
SELECT p.nombre, SUM(vd.cantidad) AS unidades_vendidas FROM productos AS p INNER JOIN ventas_detalles AS vd ON vd.producto_id = p.id GROUP BY p.id, p.nombre ORDER BY unidades_vendidas DESC;
Clientes sin ventas
LEFT JOIN conserva todos los clientes y luego filtramos los que no tienen coincidencia.
SELECT c.id, c.nombre, c.apellido FROM clientes AS c LEFT JOIN ventas AS v ON v.cliente_id = c.id WHERE v.id IS NULL;
Ventas superiores al promedio
SELECT id, cliente_id, total FROM ventas WHERE total > ( SELECT AVG(total) FROM ventas );
Validar stock antes de vender
El ejemplo descuenta stock directamente para concentrarse en SQL. En una aplicación real deberíamos verificar que exista cantidad suficiente y manejar concurrencia para evitar vender más unidades de las disponibles.
Índices
Estas columnas participan frecuentemente en JOIN y filtros.
No agregamos índices “porque sí”: los elegimos en función de las consultas reales del sistema.
CREATE INDEX idx_productos_categoria ON productos(categoria_id); CREATE INDEX idx_ventas_cliente_fecha ON ventas(cliente_id, fecha); CREATE INDEX idx_detalles_producto ON ventas_detalles(producto_id);
Qué conceptos aplicamos
- normalización;
- claves primarias y foráneas;
- INSERT y UPDATE;
- transacciones;
- JOIN;
- GROUP BY;
- subconsultas;
- índices.
Ejercicios de ampliación
- agregar formas de pago;
- crear reporte mensual;
- obtener cliente que más gastó;
- detectar productos nunca vendidos;
- registrar devoluciones.
¿Qué sigue?
20 ejercicios de SQL resueltos.
¿Te sirvió esta guía? ☕
Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:
Ruta SQL / MySQL
Ver todos los contenidos de SQL / MySQL
← Normalización de bases de datos
20 ejercicios de SQL resueltos →
Descubre más desde Club Programador
Suscríbete y recibe las últimas entradas en tu correo electrónico.
2 opiniones en “Proyecto práctico SQL/MySQL: sistema de ventas con tablas, relaciones, JOIN, reportes e índices”