Skip to content

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), nunca FLOAT.
  • Estados como ENUM para 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.

Academia Web — aprende, copia y construye.