Café y Código

6. Paso a Paso: Sistema de Boletas y Migraciones Evolutivas 🧾

1. El Reto Real: Construir una Base de Datos que Evoluciona

En los tutoriales introductorios es común ver que todos los modelos se escriben de una sola vez en un único archivo models.py y se ejecuta una única migración 0001_initial. En el mundo laboral real, ningún software se construye así.

Los sistemas crecen incrementalmente: primero lanzas un catálogo básico, luego añades el módulo de ventas, más adelante incorporas la gestión de clientes sin romper las ventas anteriores, y finalmente blindas la base de datos con restricciones de integridad y migraciones de datos.

A lo largo de esta lección construiremos el núcleo de datos de un Sistema de Punto de Venta (POS) y Facturación a través de 5 migraciones sucesivas:

  • Fase 1 (`0001`): Catálogo maestro con Categoria y Producto.
  • Fase 2 (`0002`): Transacciones de venta con Boleta y DetalleBoleta (congelando el precio histórico).
  • Fase 3 (`0003`): Incorporación de Cliente como relación opcional (null=True, blank=True) sobre tablas con datos previos.
  • Fase 4 (`0004`): Restricciones a nivel de base de datos con CheckConstraint y cálculos automáticos en save().
  • Fase 5 (`0005`): Migración de datos idempotente con RunPython para sembrar categorías maestras.

2. Línea de Tiempo Interactiva de la Base de Datos

Explora visualmente cómo cambia el código en Python, qué comando se ejecuta en la terminal y qué tablas se generan en la base de datos en cada una de las 5 fases:

LÍNEA DE TIEMPO EVOLUTIVA

Evolución de la Base de Datos: Sistema de Boletas POS

Avanza paso a paso viendo cómo nacen las tablas, se añaden relaciones y se aplican migraciones seguras sin pérdida de datos.

Archivo Migración:0001_initial.py
🎯 Objetivo del Paso 1:

Creación de Categoría y Producto

Establecer las entidades maestras de nuestro inventario con una relación 1 a Muchos (1 Categoría tiene N Productos).

💻 Terminal: Generar y Aplicar MigraciónBash / CMD
(venv) C:\proyectos\django\ventas> python manage.py makemigrations ventas
Migrations for 'ventas':
  ventas/migrations/0001_initial.py
    - Create model Categoria
    - Create model Producto

(venv) C:\proyectos\django\ventas> python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions, ventas
Running migrations:
  Applying ventas.0001_initial... OK
🐍 Código en Python (ventas/models.py)Django Models
# ventas/models.py (Fase 1)
from django.db import models

class Categoria(models.Model):
    nombre = models.CharField(max_length=50, unique=True)
    descripcion = models.TextField(blank=True)

    def __str__(self):
        return self.nombre


class Producto(models.Model):
    nombre = models.CharField(max_length=150, db_index=True)
    codigo_barra = models.CharField(max_length=30, unique=True)
    precio = models.DecimalField(max_digits=10, decimal_places=2)
    stock = models.PositiveIntegerField(default=0)
    categoria = models.ForeignKey(
        Categoria,
        on_delete=models.PROTECT,
        related_name='productos'
    )

    def __str__(self):
        return f"{self.nombre} (${self.precio})"
⚡ SQL Real Ejecutado (sqlmigrate ventas 0001)
CREATE TABLE "ventas_categoria" (
    "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    "nombre" varchar(50) NOT NULL UNIQUE,
    "descripcion" text NOT NULL
);

CREATE TABLE "ventas_producto" (
    "id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
    "nombre" varchar(150) NOT NULL,
    "codigo_barra" varchar(30) NOT NULL UNIQUE,
    "precio" numeric(10, 2) NOT NULL,
    "stock" integer NOT NULL CHECK ("stock" >= 0),
    "categoria_id" bigint NOT NULL REFERENCES "ventas_categoria" ("id")
);
🗄️ Esquema en Base de Datos (Tablas Resultantes):2 tablas activas
📋 ventas_categoria3 cols

Tabla maestra de familias de productos

🔑 PKidBIGINT
nombreVARCHAR(50) UNIQUE
descripcionTEXT
📋 ventas_producto6 cols

Catálogo de artículos vendibles

🔑 PKidBIGINT
nombreVARCHAR(150)
codigo_barraVARCHAR(30) UNIQUE
precioNUMERIC(10, 2)
stockINTEGER (>=0)
🔗 FKcategoria_idBIGINT
💡Clave de Arquitectura en Producción:

Usamos on_delete=models.PROTECT en la relación con Categoria para evitar que un administrador borre una categoría si todavía existen productos asignados a ella, protegiendo la integridad referencial.

3. Desglose Técnico Paso a Paso

Paso 1: Catálogo Base (Migración `0001_initial.py`)

Comenzamos en la aplicación ventas creando los dos modelos fundacionales de nuestro inventario:

ventas/models.py - Fase 1
PYTHON
1# ventas/models.py
2from django.db import models
3
4class Categoria(models.Model):
5 nombre = models.CharField(max_length=50, unique=True)
6 descripcion = models.TextField(blank=True)
7
8 def __str__(self):
9 return self.nombre
10
11
12class Producto(models.Model):
13 nombre = models.CharField(max_length=150, db_index=True)
14 codigo_barra = models.CharField(max_length=30, unique=True)
15 precio = models.DecimalField(max_digits=10, decimal_places=2)
16 stock = models.PositiveIntegerField(default=0)
17 categoria = models.ForeignKey(
18 Categoria,
19 on_delete=models.PROTECT,
20 related_name='productos'
21 )
22
23 def __str__(self):
24 return f"{self.nombre} (${self.precio})"
¿Por qué `on_delete=models.PROTECT`?
Si un empleado intenta eliminar la categoría "Bebidas", pero ya existen 20 productos registrados en ella, la base de datos lanzará un error ProtectedError impidiendo el borrado. Esto evita que los productos queden huérfanos o se borren por accidente.

Paso 2: Transacciones de Venta (Migración `0002_boleta_detalleboleta.py`)

Una venta requiere un patrón Maestro-Detalle (Master-Detail):

  • Boleta (Maestro): Representa la transacción global (folio único, fecha, hora, total).
  • DetalleBoleta (Detalle): Representa cada ítem vendido dentro de esa boleta (qué producto, cuántas unidades y a qué precio).
ventas/models.py - Fase 2
PYTHON
1from django.utils import timezone
2
3class Boleta(models.Model):
4 folio = models.CharField(max_length=20, unique=True, db_index=True)
5 fecha = models.DateField(default=timezone.now)
6 hora = models.TimeField(default=timezone.now)
7 total = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
8
9 def __str__(self):
10 return f"Boleta #{self.folio} - ${self.total}"
11
12
13class DetalleBoleta(models.Model):
14 boleta = models.ForeignKey(
15 Boleta,
16 on_delete=models.CASCADE,
17 related_name='detalles'
18 )
19 producto = models.ForeignKey(
20 Producto,
21 on_delete=models.PROTECT,
22 related_name='ventas_detalle'
23 )
24 # REGLA DE ARQUITECTURA CRÍTICA:
25 precio_unitario = models.DecimalField(max_digits=10, decimal_places=2)
26 cantidad = models.PositiveIntegerField(default=1)
27 total_linea = models.DecimalField(max_digits=10, decimal_places=2)
28
29 def __str__(self):
30 return f"{self.cantidad}x {self.producto.nombre} (${self.total_linea})"
⚠️ El Principio del Snapshot (Instantánea de Precios):
Muchos desarrolladores novatos omiten el campo precio_unitario en DetalleBoleta pensando: "¿Para qué guardarlo si puedo hacer detalle.producto.precio?".
¡Esto es un error financiero grave! Si vendes un café a $2.00 hoy, y dentro de 4 meses subes el precio a $3.50, al consultar las boletas del año pasado verías que todas aumentaron su valor mágicamente. En cualquier sistema de facturación, el detalle debe congelar el precio exacto al que se vendió en ese milisegundo.

Paso 3: Incorporación de Cliente Opcional (Migración `0003_cliente_boleta_cliente.py`)

Ahora la empresa decide implementar un programa de fidelización y necesita asociar boletas a clientes registrados. Sin embargo, en una cafetería o tienda muchas ventas son anónimas (público general que no entrega sus datos).

ventas/models.py - Fase 3
PYTHON
1class Cliente(models.Model):
2 rut_o_dni = models.CharField(max_length=20, unique=True)
3 nombre = models.CharField(max_length=120)
4 email = models.EmailField(blank=True)
5 telefono = models.CharField(max_length=20, blank=True)
6
7 def __str__(self):
8 return f"{self.nombre} ({self.rut_o_dni})"
9
10
11# En el modelo Boleta agregamos la clave foránea como opcional:
12class Boleta(models.Model):
13 # ... campos anteriores ...
14 cliente = models.ForeignKey(
15 Cliente,
16 on_delete=models.SET_NULL, # Si se elimina el cliente, la boleta NO se borra
17 null=True, # Admite NULL en la BD (ventas anónimas)
18 blank=True, # Opcional en formularios y panel administrativo
19 related_name='boletas'
20 )
¿Cómo afecta esto a las migraciones con datos existentes?
Si ejecutas python manage.py makemigrations sobre una tabla que ya tiene registros reales:
  • Si el campo no permite nulos (null=False), Django te detendrá en seco pidiéndote un valor por defecto para las filas anteriores.
  • Al declarar null=True, la migración ejecuta un ALTER TABLE "ventas_boleta" ADD COLUMN "cliente_id" bigint NULL; limpio e inmediato, manteniendo todas las ventas anteriores como compras anónimas sin romper el sistema.

Paso 4: Blindaje con Restricciones CheckConstraint y `save()` Automatizado

Para que el sistema sea confiable, no podemos permitir que nadie inserte un detalle con cantidad 0 o negativa, ni precios negativos. Agregamos restricciones a nivel de motor SQL en Meta.constraints:

ventas/models.py - Fase 4
PYTHON
1class DetalleBoleta(models.Model):
2 # ... campos ...
3
4 class Meta:
5 constraints = [
6 models.CheckConstraint(
7 check=models.Q(cantidad__gt=0),
8 name='chk_cantidad_positiva'
9 ),
10 models.CheckConstraint(
11 check=models.Q(precio_unitario__gte=0),
12 name='chk_precio_unitario_positivo'
13 ),
14 ]
15
16 def save(self, *args, **kwargs):
17 # 1. Si no viene precio unitario, tomar el precio actual del producto
18 if not self.precio_unitario:
19 self.precio_unitario = self.producto.precio
20
21 # 2. Calcular automáticamente el total de la línea
22 self.total_linea = self.precio_unitario * self.cantidad
23
24 super().save(*args, **kwargs)

Paso 5: Migración de Datos con `RunPython` (`0005_sembrar_categorias_iniciales.py`)

¿Cómo garantizamos que cuando un nuevo desarrollador o un servidor de despliegue clone el proyecto, existan categorías listas para usar? Creamos una migración de datos con --empty:

Crear archivo de migración vacío
CMD
1(venv) C:\proyectos\django\ventas> python manage.py makemigrations ventas --empty --name sembrar_categorias_iniciales

Luego editamos el archivo generado usando apps.get_model():

0005_sembrar_categorias_iniciales.py
PYTHON
1# ventas/migrations/0005_sembrar_categorias_iniciales.py
2from django.db import migrations
3
4CATEGORIAS = [
5 {"nombre": "Bebidas y Cafetería", "descripcion": "Café de grano, té e infusiones"},
6 {"nombre": "Pastelería", "descripcion": "Tortas y pasteles del día"},
7 {"nombre": "Snacks Salados", "descripcion": "Empanadas y sándwiches"},
8]
9
10def sembrar_categorias(apps, schema_editor):
11 # REGLA CRÍTICA: Usar apps.get_model() en vez de importar el modelo
12 Categoria = apps.get_model('ventas', 'Categoria')
13 for cat in CATEGORIAS:
14 Categoria.objects.get_or_create(
15 nombre=cat["nombre"],
16 defaults={"descripcion": cat["descripcion"]}
17 )
18
19def desempadronar_categorias(apps, schema_editor):
20 Categoria = apps.get_model('ventas', 'Categoria')
21 Categoria.objects.filter(nombre__in=[c["nombre"] for c in CATEGORIAS]).delete()
22
23class Migration(migrations.Migration):
24 dependencies = [
25 ('ventas', '0004_constraints_reglas_negocio'),
26 ]
27
28 operations = [
29 migrations.RunPython(sembrar_categorias, reverse_code=desempadronar_categorias),
30 ]

Ponte a prueba

Comprueba que comprendes el ciclo de vida de migraciones progresivas, snapshots de precios y claves foráneas opcionales.

Ko-fi
Donaciones
Apoyá cafeycodigo con un café en Ko-fi. Colaboradores: insignia, muro y zona exclusiva.