Tema
Esquema e-commerce
Base de datos completa para una tienda en línea: categorías anidadas, productos con imágenes y variantes (talla/color), inventario, clientes con direcciones, carrito, cupones, pedidos con sus partidas, pagos y envíos.
Decisiones de diseño importantes
- El pedido guarda una copia del nombre y precio del producto (
pedido_items.nombre_producto,precio_unitario). Si mañana cambias el precio, los pedidos antiguos no se alteran. - Dinero en
DECIMAL(10,2), nuncaFLOAT. - Estados como
ENUMpara que la base de datos rechace valores inválidos. - Variantes separadas del producto: una playera tiene un precio base y cada combinación talla/color tiene su propio SKU y stock.
Esquema completo
sql
-- ============================================
-- E-commerce (MySQL 8)
-- ============================================
SET NAMES utf8mb4;
CREATE TABLE categorias (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
padre_id INT UNSIGNED NULL, -- subcategorías
nombre VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
descripcion TEXT NULL,
imagen VARCHAR(255) NULL,
orden SMALLINT NOT NULL DEFAULT 0,
activo TINYINT(1) NOT NULL DEFAULT 1,
FOREIGN KEY (padre_id) REFERENCES categorias(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE productos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
categoria_id INT UNSIGNED NULL,
nombre VARCHAR(150) NOT NULL,
slug VARCHAR(170) NOT NULL UNIQUE,
sku VARCHAR(60) NULL UNIQUE,
descripcion TEXT NULL,
precio DECIMAL(10,2) NOT NULL,
precio_oferta DECIMAL(10,2) NULL,
costo DECIMAL(10,2) NULL, -- para calcular utilidad
stock INT NOT NULL DEFAULT 0, -- si no usa variantes
peso_gr INT UNSIGNED NULL,
destacado TINYINT(1) NOT NULL DEFAULT 0,
activo TINYINT(1) NOT NULL DEFAULT 1,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_en TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_prod_cat_activo (categoria_id, activo),
FULLTEXT KEY ft_prod (nombre, descripcion),
CONSTRAINT chk_precio CHECK (precio >= 0),
FOREIGN KEY (categoria_id) REFERENCES categorias(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE producto_imagenes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
producto_id INT UNSIGNED NOT NULL,
archivo VARCHAR(255) NOT NULL,
alt VARCHAR(150) NULL,
orden SMALLINT NOT NULL DEFAULT 0,
FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE producto_variantes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
producto_id INT UNSIGNED NOT NULL,
sku VARCHAR(60) NOT NULL UNIQUE,
talla VARCHAR(20) NULL,
color VARCHAR(40) NULL,
precio DECIMAL(10,2) NULL, -- NULL = usa el precio del producto
stock INT NOT NULL DEFAULT 0,
UNIQUE KEY uq_variante (producto_id, talla, color),
FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE clientes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
apellidos VARCHAR(100) NULL,
email VARCHAR(150) NOT NULL UNIQUE,
telefono VARCHAR(20) NULL,
password_hash VARCHAR(255) NULL, -- NULL = compró como invitado
acepta_promos TINYINT(1) NOT NULL DEFAULT 0,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE direcciones (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
cliente_id INT UNSIGNED NOT NULL,
alias VARCHAR(50) NULL, -- 'Casa', 'Oficina'
destinatario VARCHAR(150) NOT NULL,
calle VARCHAR(150) NOT NULL,
numero_ext VARCHAR(20) NOT NULL,
numero_int VARCHAR(20) NULL,
colonia VARCHAR(100) NOT NULL,
ciudad VARCHAR(100) NOT NULL,
estado VARCHAR(100) NOT NULL,
cp CHAR(5) NOT NULL,
telefono VARCHAR(20) NULL,
referencias VARCHAR(255) NULL,
predeterminada TINYINT(1) NOT NULL DEFAULT 0,
FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE cupones (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
codigo VARCHAR(30) NOT NULL UNIQUE,
tipo ENUM('porcentaje','monto') NOT NULL,
valor DECIMAL(10,2) NOT NULL,
minimo_compra DECIMAL(10,2) NOT NULL DEFAULT 0,
usos_maximos INT UNSIGNED NULL,
usos INT UNSIGNED NOT NULL DEFAULT 0,
inicia_en DATETIME NULL,
expira_en DATETIME NULL,
activo TINYINT(1) NOT NULL DEFAULT 1
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE carritos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
cliente_id INT UNSIGNED NULL,
token CHAR(36) NOT NULL UNIQUE, -- para invitados (cookie)
actualizado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE carrito_items (
carrito_id INT UNSIGNED NOT NULL,
producto_id INT UNSIGNED NOT NULL,
variante_id INT UNSIGNED NULL,
cantidad SMALLINT UNSIGNED NOT NULL DEFAULT 1,
UNIQUE KEY uq_item (carrito_id, producto_id, variante_id),
FOREIGN KEY (carrito_id) REFERENCES carritos(id) ON DELETE CASCADE,
FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE CASCADE,
FOREIGN KEY (variante_id) REFERENCES producto_variantes(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE pedidos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
folio VARCHAR(20) NOT NULL UNIQUE, -- 'MX-2026-000123'
cliente_id INT UNSIGNED NULL,
email VARCHAR(150) NOT NULL,
estado ENUM('pendiente','pagado','preparando','enviado','entregado','cancelado','reembolsado')
NOT NULL DEFAULT 'pendiente',
subtotal DECIMAL(10,2) NOT NULL,
descuento DECIMAL(10,2) NOT NULL DEFAULT 0,
envio DECIMAL(10,2) NOT NULL DEFAULT 0,
impuestos DECIMAL(10,2) NOT NULL DEFAULT 0,
total DECIMAL(10,2) NOT NULL,
cupon_id INT UNSIGNED NULL,
direccion_envio JSON NULL, -- copia de la dirección al momento de comprar
notas VARCHAR(500) NULL,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizado_en TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP,
KEY idx_pedidos_estado_fecha (estado, creado_en),
KEY idx_pedidos_cliente (cliente_id),
FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE SET NULL,
FOREIGN KEY (cupon_id) REFERENCES cupones(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE pedido_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id INT UNSIGNED NOT NULL,
producto_id INT UNSIGNED NULL, -- NULL si el producto se borró
variante_id INT UNSIGNED NULL,
nombre_producto VARCHAR(200) NOT NULL, -- copia histórica
sku VARCHAR(60) NULL,
cantidad SMALLINT UNSIGNED NOT NULL,
precio_unitario DECIMAL(10,2) NOT NULL,
total DECIMAL(10,2) AS (cantidad * precio_unitario) STORED,
KEY idx_items_producto (producto_id),
FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADE,
FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE SET NULL,
FOREIGN KEY (variante_id) REFERENCES producto_variantes(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE pagos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id INT UNSIGNED NOT NULL,
proveedor ENUM('stripe','mercadopago','paypal','transferencia','efectivo') NOT NULL,
referencia VARCHAR(120) NULL, -- id de la transacción del proveedor
monto DECIMAL(10,2) NOT NULL,
estado ENUM('pendiente','aprobado','rechazado','reembolsado') NOT NULL DEFAULT 'pendiente',
respuesta JSON NULL, -- payload del webhook
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_pagos_ref (referencia),
FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE envios (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pedido_id INT UNSIGNED NOT NULL UNIQUE,
paqueteria VARCHAR(50) NULL, -- 'Estafeta', 'DHL', 'FedEx'
guia VARCHAR(80) NULL,
enviado_en DATETIME NULL,
entregado_en DATETIME NULL,
FOREIGN KEY (pedido_id) REFERENCES pedidos(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE resenas (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
producto_id INT UNSIGNED NOT NULL,
cliente_id INT UNSIGNED NOT NULL,
calificacion TINYINT UNSIGNED NOT NULL,
comentario TEXT NULL,
aprobada TINYINT(1) NOT NULL DEFAULT 0,
creado_en TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_resena (producto_id, cliente_id), -- una reseña por cliente
CONSTRAINT chk_calif CHECK (calificacion BETWEEN 1 AND 5),
FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE CASCADE,
FOREIGN KEY (cliente_id) REFERENCES clientes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ============================================
-- Datos de ejemplo
-- ============================================
INSERT INTO categorias (nombre, slug) VALUES ('Café', 'cafe'), ('Accesorios', 'accesorios'), ('Regalos', 'regalos');
INSERT INTO productos (categoria_id, nombre, slug, sku, precio, precio_oferta, costo, stock, destacado) VALUES
(1, 'Café de Chiapas 500 g', 'cafe-chiapas-500', 'CAF-CHI-500', 189.00, NULL, 95.00, 120, 1),
(1, 'Café de Veracruz 500 g', 'cafe-veracruz-500', 'CAF-VER-500', 175.00, 159.00, 88.00, 80, 0),
(1, 'Café de Oaxaca Pluma 250 g', 'cafe-oaxaca-250', 'CAF-OAX-250', 129.00, NULL, 60.00, 45, 1),
(2, 'Prensa francesa 600 ml', 'prensa-francesa-600', 'ACC-PRE-600', 449.00, 399.00, 210.00, 25, 1),
(2, 'Taza de barro', 'taza-barro', 'ACC-TAZ-01', 95.00, NULL, 35.00, 200, 0),
(3, 'Kit regalo cafetero', 'kit-regalo', 'REG-KIT-01', 799.00, NULL, 420.00, 15, 1);
INSERT INTO cupones (codigo, tipo, valor, minimo_compra, expira_en) VALUES
('BIENVENIDA10', 'porcentaje', 10, 300, '2026-12-31 23:59:59'),
('ENVIOGRATIS', 'monto', 99, 500, NULL);Consultas típicas de la tienda
sql
-- Catálogo: precio final (oferta si existe) y stock total incluyendo variantes
SELECT p.id, p.nombre, p.slug,
COALESCE(p.precio_oferta, p.precio) AS precio_final,
p.precio_oferta IS NOT NULL AS en_oferta,
COALESCE(SUM(v.stock), p.stock) AS stock_total,
(SELECT archivo FROM producto_imagenes i WHERE i.producto_id = p.id ORDER BY orden LIMIT 1) AS imagen
FROM productos p
LEFT JOIN producto_variantes v ON v.producto_id = p.id
WHERE p.activo = 1
GROUP BY p.id
ORDER BY p.destacado DESC, p.creado_en DESC;
-- Validar un cupón
SELECT * FROM cupones
WHERE codigo = 'BIENVENIDA10' AND activo = 1
AND (inicia_en IS NULL OR inicia_en <= NOW())
AND (expira_en IS NULL OR expira_en >= NOW())
AND (usos_maximos IS NULL OR usos < usos_maximos);
-- Detalle de un pedido con sus partidas
SELECT pe.folio, pe.estado, pe.total, pi.nombre_producto, pi.cantidad, pi.precio_unitario, pi.total AS importe
FROM pedidos pe JOIN pedido_items pi ON pi.pedido_id = pe.id
WHERE pe.folio = 'MX-2026-000123';
-- Productos con poco inventario
SELECT nombre, sku, stock FROM productos WHERE activo = 1 AND stock <= 10 ORDER BY stock;
-- Calificación promedio por producto
SELECT p.nombre, ROUND(AVG(r.calificacion), 1) AS promedio, COUNT(r.id) AS resenas
FROM productos p LEFT JOIN resenas r ON r.producto_id = p.id AND r.aprobada = 1
GROUP BY p.id ORDER BY promedio DESC;Crear un pedido de forma segura (transacción)
sql
START TRANSACTION;
-- 1. Bloquear y verificar stock
SELECT stock FROM productos WHERE id = 1 FOR UPDATE; -- en tu código: si stock < cantidad -> ROLLBACK
-- 2. Crear el pedido
INSERT INTO pedidos (folio, cliente_id, email, subtotal, envio, total)
VALUES ('MX-2026-000124', 7, 'ana@correo.com', 378.00, 99.00, 477.00);
SET @pedido = LAST_INSERT_ID();
-- 3. Partidas con copia del nombre y precio
INSERT INTO pedido_items (pedido_id, producto_id, nombre_producto, sku, cantidad, precio_unitario)
SELECT @pedido, id, nombre, sku, 2, COALESCE(precio_oferta, precio) FROM productos WHERE id = 1;
-- 4. Descontar inventario
UPDATE productos SET stock = stock - 2 WHERE id = 1;
COMMIT;Más reportes (ventas por mes, top productos, ticket promedio) en Consultas útiles.
Pagos con webhooks
Nunca marques un pedido como pagado porque el navegador regresó de Stripe o Mercado Pago. Espera el webhook del proveedor, guarda el payload en pagos.respuesta y sólo entonces cambia el estado.