📚 Unidad ISS-06 · Sale y ProductSale — capa 🧠 APRENDER
🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE)
Capa Página Para qué 🧠 Aprender esta página comprender, explicar y relacionar 🛠 Construir Sale y ProductSale ejecutar, programar y verificar ✅ GATE Cierre de la unidad condición para pasar al bloque siguiente
🖥 Presentación del ISS
Presentación de la unidad. Diapositivas de ISS-06 — Sale y ProductSale (20 diapositivas). Se visualiza aquí, dentro del sitio.
🎬 Video explicativo
Recorrido audiovisual de la unidad. El video recorre el ISS técnico de ISS-06 — Sale y ProductSale, bloque por bloque.
6:40 min · narración en español · subtítulos activables desde el reproductor.
Infografía

Mapa conceptual
- Gerente de queries
- Archivo: apps/sale/managers.py
- SaleQuerySet: queryset base
- SaleManager.from_queryset: manager personalizado
- Registro: diferido hasta ISS-11
- Modelo Sale
- Relación: FK Client con RESTRICT y db_column 'client_id'
- Fechas e importes: sale_date, subtotal, tax, discounts, total
- Validación: sales_status_valid (CheckConstraint)
- Validación: sales_amounts_non_neg (CheckConstraint importes >= 0)
- Método void
- Transacción: transaction.atomic()
- Bloqueo: select_for_update() en venta, líneas y productos
- Operación: restauración de stock en productos activos
- Estado: actualización de status a INACTIVE con update_fields
- Modelo ProductSale
- Relación Sale: FK con related_name 'lines'
- Relación Product: FK con related_name 'sale_lines'
- Detalle: quantity (PositiveIntegerField >= 1)
- Precio y totales: unit_price congelado, line_total calculado en save()
- Decisiones de arquitectura
- Inexistencia de ManyToManyField
- Preservación de precio histórico
- Navegación: sale.lines.all()
- Migraciones CLI y Gate
- Comandos: makemigrations sale y migrate
- Criterios de aceptación: AC-06-01 a AC-06-04
- Evidencias: EVI-06-01 (migración) y EVI-06-02 (test precio histórico)
- Resultado del Gate: PASS
Guía de Estudio Pedagógica: ISS-06 — Sale y ProductSale en Django ORM
1. Objetivos y Requisitos Técnicos del ISS-06
El diseño de un subsistema transaccional dentro de una arquitectura comercial-empresarial exige trasponer las fronteras del modelado escolar de datos y asumir las complejidades inherentes a la persistencia financiera e inventariable. En la unidad ISS-06 del proyecto StoreLab, el objetivo primordial radica en la construcción e integración de los modelos de cabecera (Sale) y detalle (ProductSale) apoyándose de forma nativa en el ORM de Django. Las ventas no constituyen meras entidades pasivas de consulta; representan compromisos contractuales, fiscales e inventariables cuya ejecución debe ser auditable, inmutable y resistente a fallos de concurrencia. Por consiguiente, articular la cabecera y el detalle transaccional sobre un esquema relacional multidialecto impone garantizar la integridad referencial, la precisión aritmética monetaria y el aislamiento estricto de precios históricos directamente en la capa de la base de datos.
La razón crítica para superar el modelo analítico trivial radica en que una transacción comercial real no puede ser representada mediante una relación "muchos a muchos" simplificada o carente de contexto. Cada línea de venta debe capturar explícitamente el instante temporal del intercambio, congelar el valor comercial pactado y registrar la cantidad física comprometida. Garantizar esto exige trasladar la responsabilidad del cálculo y las validaciones de rango numérico hacia el motor relacional subyacente a través de restricciones estipuladas en el ORM (CheckConstraint), previniendo que estados incongruentes, montos negativos o inconsistencias de stock se filtren hacia la capa de persistencia.
| Requisito Previo / Condición | Justificación Arquitectónica Directa |
|---|---|
ISS-04 superado (Modelo Client) |
Proporciona la entidad Client registrada en la tabla física clients, indispensable para establecer la relación de propiedad de la transacción (client_id) mediante claves foráneas restringidas. |
ISS-05 superado (Modelos ProductType y Product) |
Proporciona el catálogo activo y la entidad Product con control de precio base y existencias (stock), insumos necesarios para validar existencias y capturar el precio de venta histórico en los detalles. |
Entorno .venv activo y motor configurado (ISS-01) |
Garantiza que las migraciones del ORM y la validación de restricciones Check Constraints se ejecuten homogéneamente sobre el motor de base de datos relacional seleccionado. |
Las metas operativas específicas que estructuran esta unidad técnica se desglosan a continuación:
- Representación rigurosa del encabezado de venta (
Sale): Capturar el cliente asociado, la fecha de la transacción (sale_date), los componentes financieros (subtotal,tax,discounts,total) e heredar la gestión de auditoría y estado (status,created_at,updated_at) desdeStoreLabModel. - Detalle transaccional inmutable (
ProductSale): Mapear la tabla intermedia explícita que vincula la venta con cada producto, capturando obligatoriamente la cantidad (quantity), el precio unitario histórico (unit_price) y el total calculado de línea (line_total). - Redefinición y aislamiento del Manager (
SaleManager): Separar la capa de lógica de consulta y persistencia creando un módulo especializado (apps/sale/managers.py) que extiende deSaleQuerySetmediantemodels.Manager.from_queryset(), preparando la infraestructura para futuras operaciones transaccionales complejas de la unidad ISS-11. - Implementación del método de anulación atómica (
void()): Incorporar en el modeloSaleun mecanismo de reversión de venta empaquetado en una transacción atómica (transaction.atomic()), con bloqueos pesimistas a nivel de fila (select_for_update()), devolviendo de forma ordenada el stock consumido a la entidadProducte inactivando la venta y sus líneas.
Comprender la ejecución sintáctica y arquitectónica de estas metas requiere primero dominar el vocabulario técnico y los métodos especializados que ofrece el ORM de Django para la gestión transaccional.
2. Conceptos Esenciales y Vocabulario Técnico
El desarrollo de sistemas transaccionales con Django ORM demanda un dominio preciso de su API avanzada y de las primitivas de concurrencia de las bases de datos relacionales. En entornos comerciales de alta densidad, la falta de rigor en el uso de métodos de consulta o el desconocimiento del comportamiento del ORM puede derivar en condiciones de carrera (race conditions), interbloqueos (deadlocks), pérdida de precisión monetaria o corrupción silenciosa del inventario. Por ello, el léxico técnico utilizado en el ISS-06 trasciende la sintaxis de Python para convertirse en decisiones de diseño con impacto directo en el motor de base de datos.
A continuación, se evalúa el vocabulario clave y los métodos fundamentales integrados en esta unidad, detallando su categoría, definición estricta fundada en la fuente y su impacto transaccional real:
| Término / Método | Categoría (ORM / BD / Concurrencia) | Definición Técnica Grounded | Efecto Transaccional / "So What?" |
|---|---|---|---|
SaleManager |
ORM / Lógica de Dominio | Clase personalizada de gestión de datos que hereda de models.Manager.from_queryset(SaleQuerySet). En ISS-06 actúa como un contenedor estructural base (pass). |
Aísla la interfaz de consulta del modelo Sale, garantizando que las futuras operaciones avanzadas de negocio (como register() en el ISS-11) permanezcan desacopladas del modelo. |
from_queryset |
ORM / Extensibilidad | Método de clase de models.Manager que construye dinámicamente un Manager a partir de una clase QuerySet personalizada. |
Permite encadenar métodos del QuerySet directamente desde el Manager (Sale.objects.method()), manteniendo la fluidez sintáctica del ORM sin duplicar código. |
transaction.atomic() |
Concurrencia / Base de Datos | Context manager o decorador que delimita un bloque de transacción SQL de base de datos (BEGIN/COMMIT/ROLLBACK). | Garantiza las propiedades ACID: si ocurre una excepción dentro del bloque, todas las modificaciones físicas a la base de datos se revierten por completo. |
select_for_update() |
Concurrencia / Base de Datos | Método de QuerySet que traduce la consulta SQL a una cláusula de bloqueo pesimista FOR UPDATE sobre las filas seleccionadas. |
Bloquea las filas en la BD hasta que la transacción finalice, impidiendo que otras transacciones concurrentes modifiquen el stock o el estado del registro simultáneamente. |
quantize() |
ORM / Aritmética Decimal | Método del tipo Decimal de Python (quantize(Decimal("0.01"))) utilizado para redondear un número a una precisión fija. |
Elimina errores de redondeo de punto flotante en montos financieros, garantizando que los centavos de line_total coincidan exactamente con las reglas contables. |
update_fields |
ORM / Persistencia | Argumento opcional del método save() que especifica en una lista las columnas exactas que deben actualizarse en la orden SQL UPDATE. |
Evita sobreescribir columnas concurrentes no modificadas y optimiza el rendimiento generando sentencias SQL reducidas que solo impactan los campos indicados. |
related_name |
ORM / Relaciones | Atributo de ForeignKey que define el nombre de la relación inversa desde el modelo apuntado hacia el modelo origen. |
Permite la navegación natural en el código (e.g., sale.lines.all() o product.sale_lines.all()), sustituyendo los sufijos predeterminados _set. |
db_column |
BD / Mapeo Físico | Atributo de ForeignKey que especifica el nombre exacto de la columna física de clave foránea en la tabla SQL. |
Garantiza el cumplimiento de las convenciones explícitas de nombrado de la base de datos (e.g., client_id, sale_id), independientemente de los objetos en Python. |
RESTRICT |
BD / Integridad Referencial | Opción de on_delete (models.RESTRICT) que impide la eliminación de un registro padre si existen registros hijos vinculados. |
Previene la eliminación accidental de clientes o productos que contengan ventas asociadas, lanzando un RestrictedError para proteger la integridad del sistema. |
La asimilación de estos conceptos transaccionales permite comprender cómo se estructuran físicamente los componentes del código en el árbol de archivos del proyecto.
3. Estructura de Archivos, Paquetes y Cambios en el Proyecto
El mantenimiento de una arquitectura empresarial limpia en Django requiere distanciar explícitamente las responsabilidades de cada archivo dentro de las aplicaciones. En el paquete apps/sale/, la estructura inicial generada por el comando startapp en el ISS-02 resulta insuficiente cuando se incorporan gestores de datos personalizados y lógica transaccional avanzada. La creación explícita de apps/sale/managers.py independiza la construcción de consultas (QuerySet) y la ejecución de comandos de persistencia, evitando que el archivo apps/sale/models.py se transforme en un bloque monolítico e inmanttenible.
La reescritura completa de apps/sale/models.py para el ISS-06 implica descartar cualquier comentario inicial generado por la CLI y asumir el control total de los modelos de persistencia, importando el nuevo manager y estructurando las entidades Sale y ProductSale.
El estado de la estructura de archivos en la aplicación de ventas antes y después de la ejecución de esta unidad se ilustra a continuación:
ESTADO ANTES (ISS-02):
apps/sale/
├── __init__.py
├── admin.py
├── apps.py
├── migrations/
│ └── __init__.py
├── models.py # Archivo inicial generado por startapp (vacío/solo comentarios)
├── tests.py
└── views.py
ESTADO DESPUÉS (ISS-06):
apps/sale/
├── __init__.py
├── admin.py
├── apps.py
├── managers.py # NUEVO: Contiene SaleQuerySet y SaleManager (base extensible)
├── migrations/
│ └── __init__.py
├── models.py # REESCRITO: Contiene los modelos Sale y ProductSale con void() y save()
├── tests.py
└── views.py
Para concretar esta transformación estructural, se ejecutan los siguientes comandos en la interfaz de línea de comandos (CLI) del sistema, utilizando cat wrappers para garantizar la creación exacta de los archivos en disco:
- Creación del módulo de managers (
apps/sale/managers.py): Se utiliza el comandocatpara generar el archivo de managers que alojaSaleQuerySetySaleManager:
cat > apps/sale/managers.py <<'EOF'
from django.db import models
class SaleQuerySet(models.QuerySet):
pass
class SaleManager(models.Manager.from_queryset(SaleQuerySet)):
pass
EOF
- Sobreescritura de los modelos (
apps/sale/models.py): Se reemplaza el contenido por defecto del archivomodels.pymediante la redirección del flujo de comandos, inyectando la definición completa de las entidades de negocioSaleyProductSale, junto con la importación deClient,Product,StoreLabModelySaleManager:
cat > apps/sale/models.py <<'EOF'
from decimal import Decimal
from django.core.validators import MinValueValidator
from django.db import models, transaction
from apps.client.models import Client
from apps.common.models import StoreLabModel
from apps.common.status import RecordStatus
from apps.product.models import Product
from apps.sale.managers import SaleManager
# [Definición de clases Sale y ProductSale presentadas en el análisis semántico]
EOF
Una vez establecida la estructura física y desplegado el código de los archivos en el entorno de desarrollo, corresponde analizar detalladamente la responsabilidad pedagógica y técnica de cada bloque semántico construido.
4. Explicación y Análisis por Bloques Semánticos
El análisis de código mediante la técnica de despiece por bloques semánticos es una estrategia pedagógica esencial para entender cómo cada línea contribuye a la solidez del sistema. En el modelado transaccional de Django, ninguna instrucción es superficial: la selección de un tipo de dato decimal evita imprecisiones en auditorías contables, las restricciones CheckConstraint garantizan la validez de los datos directamente en el motor relacional, y la orquestación de bloqueos pesimistas protege la consistencia del inventario.
A continuación, se descomponen y analizan minuciosamente los bloques de código que conforman los archivos creados.
4.1 Módulo apps/sale/managers.py
from django.db import models
class SaleQuerySet(models.QuerySet):
pass
class SaleManager(models.Manager.from_queryset(SaleQuerySet)):
pass
class SaleQuerySet(models.QuerySet):- QUÉ ES $\rightarrow$ Una subclase personalizada de
models.QuerySet. - QUÉ HACE $\rightarrow$ Declara una estructura vacía (
pass) en la unidad ISS-06 que servirá de base en etapas futuras. - POR QUÉ EXISTE $\rightarrow$ Diseñada para desacoplar las operaciones de consulta a nivel de conjunto de datos. En el ISS-06 se establece intencionalmente como un marcador de posición (placeholder) mínimo, preparando la infraestructura para la incorporación del método transaccional de registro (
register()) en el ISS-11. - CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con
models.Manager.from_queryset(), que absorberá dinámicamente sus métodos para exponerlos a través del Managerobjects.
- QUÉ ES $\rightarrow$ Una subclase personalizada de
class SaleManager(models.Manager.from_queryset(SaleQuerySet)):- QUÉ ES $\rightarrow$ La redefinición del Manager por defecto para el modelo de ventas.
- QUÉ HACE $\rightarrow$ Instancia un Manager de Django enlazado dinámicamente a la clase
SaleQuerySet. - POR QUÉ EXISTE $\rightarrow$ Garantiza que el atributo
Sale.objectsutilice la interfaz extensible del proyecto, permitiendo que cualquier consulta o método futuro del QuerySet sea directamente accesible desde la clase del modelo. - CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona directamente con la asignación
objects = SaleManager()en la definición del modeloSale.
4.2 Modelo Sale en apps/sale/models.py
Bloque 1: Campos monetarios y clave foránea a Client
class Sale(StoreLabModel):
client = models.ForeignKey(
Client,
on_delete=models.RESTRICT,
related_name="sales",
db_column="client_id",
)
sale_date = models.DateTimeField()
subtotal = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
tax = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
discounts = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
total = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0.00"))
objects = SaleManager()
client = models.ForeignKey(...):- QUÉ ES $\rightarrow$ Una relación de clave foránea hacia la entidad
Client. - QUÉ HACE $\rightarrow$ Vincula la venta con un cliente registrado en la tabla
clients. Aplicaon_delete=models.RESTRICTpara bloquear el borrado físico del cliente si tiene ventas, asigna el nombre de columna físicaclient_idmediantedb_columny establece el acceso inversoclient.sales.all(). - POR QUÉ EXISTE $\rightarrow$ Garantiza la integridad referencial y la trazabilidad comercial a nivel de base de datos.
- CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con el modelo
Client(apps/client/models.py) introducido en el ISS-04.
- QUÉ ES $\rightarrow$ Una relación de clave foránea hacia la entidad
- Campos Monetarios (
subtotal,tax,discounts,total):- QUÉ ES $\rightarrow$ Atributos numéricos de alta precisión definidos mediante
DecimalField(max_digits=12, decimal_places=2). - QUÉ HACE $\rightarrow$ Almacena importes financieros de hasta 10 dígitos enteros y 2 decimales, inicializados predeterminadamente en
Decimal("0.00"). - POR QUÉ EXISTE $\rightarrow$ Evita errores de redondeo inherentes a los tipos de punto flotante binary (
FloatField).DecimalFieldse mapea a tipos exactos en SQL (e.g.,DECIMAL/NUMERIC), salvaguardando el rigor contable del sistema. - CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con las restricciones numéricas de la metaclase y con la consolidación de valores calculados desde
ProductSale.
- QUÉ ES $\rightarrow$ Atributos numéricos de alta precisión definidos mediante
Bloque 2: Metaclase y restricciones Check Constraints
class Meta:
db_table = "sales"
verbose_name = "venta"
verbose_name_plural = "ventas"
ordering = ["-sale_date", "-id"]
constraints = [
StoreLabModel.status_constraint("sales_status_valid"),
models.CheckConstraint(
condition=models.Q(subtotal__gte=0)
& models.Q(tax__gte=0)
& models.Q(discounts__gte=0)
& models.Q(total__gte=0),
name="sales_amounts_non_neg",
),
]
constraints = [...]:- QUÉ ES $\rightarrow$ Lista de restricciones de base de datos declaradas en la metaclase.
- QUÉ HACE $\rightarrow$ Invoca
StoreLabModel.status_constraint("sales_status_valid")para validar el estado del registro y define la Check Constraintsales_amounts_non_neg, exigiendo quesubtotal,tax,discountsytotalsean mayores o iguales a cero ($\ge 0$). - POR QUÉ EXISTE $\rightarrow$ Traslada la validación de montos no negativos directamente al motor relacional SQL, previniendo la inserción de datos corruptos mediante consultas directas o fallos de la aplicación.
- CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con la clase base
StoreLabModel(apps/common/models.py) del ISS-04 y con el estado general de las transacciones.
Bloque 3: Método de anulación atómica void()
def void(self):
with transaction.atomic():
sale = Sale.objects.select_for_update().get(pk=self.pk)
if sale.status == RecordStatus.INACTIVE:
return sale
lines = sale.lines.select_related("product").select_for_update().order_by("product_id")
for line in lines:
if line.status != RecordStatus.ACTIVE:
continue
product = Product.objects.select_for_update().get(pk=line.product_id)
product.stock += line.quantity
product.save(update_fields=["stock", "updated_at"])
line.status = RecordStatus.INACTIVE
line.save(update_fields=["status", "updated_at"])
sale.status = RecordStatus.INACTIVE
sale.save(update_fields=["status", "updated_at"])
return sale
with transaction.atomic()::- QUÉ ES $\rightarrow$ Bloque de contexto para el control de transacciones atómicas.
- QUÉ HACE $\rightarrow$ Agrupa las operaciones de lectura con bloqueo, la devolución de stock y la inactivación de filas en un único bloque ACID SQL.
- POR QUÉ EXISTE $\rightarrow$ Garantiza atomicidad; si ocurre algún fallo durante la anulación, ningún registro se modifica de forma parcial en la base de datos.
- CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con el gestor de transacciones de Django (
django.db.transaction).
lines = sale.lines.select_related("product").select_for_update().order_by("product_id"):- QUÉ ES $\rightarrow$ Consulta con carga previa, bloqueo pesimista y ordenamiento estricto.
- QUÉ HACE $\rightarrow$ Obtiene las líneas del detalle asociadas, aplica la cláusula SQL
FOR UPDATEsobre ellas y las ordena numéricamente porproduct_id. - POR QUÉ EXISTE $\rightarrow$ El ordenamiento estricto por
product_ides vital para prevenir deadlocks (bloqueos mutuos) cuando múltiples peticiones intentan anular simultáneamente ventas que contienen los mismos productos en diferente orden. - CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con la tabla
product_salesy el inventario enproducts.
product.save(update_fields=["stock", "updated_at"]):- QUÉ ES $\rightarrow$ Invocación de guardado parcial con restricción explícita de columnas.
- QUÉ HACE $\rightarrow$ Fuerza a Django a generar una orden SQL
UPDATE products SET stock = ..., updated_at = ... WHERE id = .... - POR QUÉ EXISTE $\rightarrow$ En sistemas de alta concurrencia, omitir
update_fieldsprovoca que Django emita una sentenciaUPDATEcompleta con todas las columnas del modelo. Si un usuario administrativo modifica concurrentemente el nombre o la descripción del producto, una actualización global sinupdate_fieldssobreescribiría y sobrescribiría ruidosamente dichos cambios no relacionados. - CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona directamente con el modelo
Product(apps/product/models.py) y las políticas de concurrencia segura.
4.3 Modelo ProductSale en apps/sale/models.py
Bloque 1: Claves foráneas y datos de detalle
class ProductSale(StoreLabModel):
sale = models.ForeignKey(
Sale,
on_delete=models.RESTRICT,
related_name="lines",
db_column="sale_id",
)
product = models.ForeignKey(
Product,
on_delete=models.RESTRICT,
related_name="sale_lines",
db_column="product_id",
)
quantity = models.PositiveIntegerField(validators=[MinValueValidator(1)])
unit_price = models.DecimalField(max_digits=12, decimal_places=2)
line_total = models.DecimalField(max_digits=12, decimal_places=2)
saleyproduct(ForeignKey):- QUÉ ES $\rightarrow$ Relaciones de clave foránea hacia los modelos
SaleyProduct. - QUÉ HACE $\rightarrow$ Conectan la línea de detalle con su cabecera y el producto del catálogo. Asignan
on_delete=models.RESTRICT, columnas físicas explícitas (sale_id,product_id) y nombres inversos navegables (linesysale_lines). - POR QUÉ EXISTE $\rightarrow$ Materializan la entidad intermedia débil de la transacción, necesaria para capturar datos específicos de cada línea.
- CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con las tablas
salesyproducts.
- QUÉ ES $\rightarrow$ Relaciones de clave foránea hacia los modelos
unit_priceyline_total(DecimalField):- QUÉ ES $\rightarrow$ Campos numéricos decimales para almacenar precios e importes.
- QUÉ HACE $\rightarrow$
unit_pricecongela el precio unitario pactado al momento de vender.line_totalalmacena el subtotal derivado de la línea. - POR QUÉ EXISTE $\rightarrow$ Garantiza la inmutabilidad de los registros financieros, asegurando que modificaciones futuras en el catálogo no alteren transacciones pasadas.
- CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con el método
save()deProductSale.
Bloque 2: Metaclase y restricciones Check Constraints del detalle
class Meta:
db_table = "product_sales"
verbose_name = "detalle de venta"
verbose_name_plural = "detalles de venta"
ordering = ["id"]
constraints = [
StoreLabModel.status_constraint("product_sales_status_valid"),
models.CheckConstraint(
condition=models.Q(quantity__gte=1)
& models.Q(unit_price__gte=0)
& models.Q(line_total__gte=0),
name="psale_amounts_valid",
),
]
name="psale_amounts_valid":- QUÉ ES $\rightarrow$ Restricción Check SQL para la tabla
product_sales. - QUÉ HACE $\rightarrow$ Garantiza que la cantidad sea al menos 1 (
quantity__gte=1) y que el precio unitario y el total de línea no sean negativos ($\ge 0$). - POR QUÉ EXISTE $\rightarrow$ Asegura la integridad física de los datos contra anomalías sintácticas o lógicas a nivel de base de datos.
- CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con la tabla relacional
product_sales.
- QUÉ ES $\rightarrow$ Restricción Check SQL para la tabla
Bloque 3: Recálculo obligatorio en el método save()
def save(self, *args, **kwargs):
self.line_total = (self.unit_price * self.quantity).quantize(Decimal("0.01"))
super().save(*args, **kwargs)
self.line_total = (self.unit_price * self.quantity).quantize(Decimal("0.01")):- QUÉ ES $\rightarrow$ Sobreescritura del método de persistencia
save()del modelo. - QUÉ HACE $\rightarrow$ Recomputa automáticamente el campo
line_totalmultiplicando la cantidad por el precio unitario y aplicando redondeo exacto a dos decimales conquantize()antes de invocar asuper().save(). - POR QUÉ EXISTE $\rightarrow$ Previene la inconsistencia interna de la fila, impidiendo que se persista un subtotal de línea desalineado con la aritmética real de los atributos.
- CON QUÉ SE RELACIONA $\rightarrow$ Se relaciona con el campo
line_totalde la base de datos y los estándares de precisión monetaria.
- QUÉ ES $\rightarrow$ Sobreescritura del método de persistencia
Desglosada la sintaxis del código, es imprescindible evaluar los fundamentos teóricos y las decisiones de arquitectura de alto nivel que sustentan este diseño.
5. Análisis Profundo de Arquitectura y Patrones Transaccionales
La selección de patrones de modelado para transacciones comerciales constituye el núcleo crítico que diferencia una aplicación de producción de un prototipo académico. Un error en el diseño del esquema de ventas puede ocasionar descuadres contables irrecuperables, pérdida de la trazabilidad fiscal o inconsistencias en el inventario ante peticiones concurrentes.
A continuación, se analizan críticamente las tres grandes decisiones de arquitectura del ISS-06:
5.1 Rechazo de ManyToManyField directo
En modelos de datos simplificados, suele recurrirse al uso de un campo ManyToManyField directo entre la entidad principal y el catálogo (Sale $\leftrightarrow$ Product). En el diseño de StoreLab, este enfoque fue explícitamente descartado por inviabilidad arquitectónica:
Fundamento Arquitectónico: Una relación
ManyToManyFieldestándar sin modelo intermedio explícito delega en Django la creación de una tabla oculta que únicamente almacena el par de claves foráneas(sale_id, product_id). Esta estructura imposibilita almacenar atributos contextuales indispensables de la transacción, como la cantidad de unidades vendidas, el precio unitario aplicado en ese instante y el total parcial de la línea.
DESAPROBADO (ManyToManyField directo):
[Sale] 1 ──────────── N (tabla oculta: sale_id, product_id) ──────────── N [Product]
* Imposibilidad de almacenar cantidad vendida.
* Imposibilidad de congelar el precio unitario histórico.
* Ausencia de subtotales por línea.
APROBADO (Tabla intermedia explícita ISS-06):
[Sale] 1 ─────── N [ProductSale] N ─────── 1 [Product]
├── quantity (>= 1)
├── unit_price (Snapshot histórico congelado)
└── line_total (Recalculado con quantize)
La tabla comparativa siguiente ilustra la superioridad técnica del modelo intermedio explícito sobre el enfoque de muchos a muchos directo:
| Dimensión de Diseño | ManyToManyField Directo |
Modelo Intermedio Explícito (ProductSale) |
|---|---|---|
| Almacenamiento de Cantidad | Imposible. Requiere tablas auxiliares o asumir siempre 1 unidad. | Soportado de forma nativa: quantity almacena las unidades exactas. |
| Congelamiento de Precio | Imposible. Depende del precio dinámico en Product.price. |
Garantizado: unit_price almacena un Snapshot inmutable. |
| Subtotales por Línea | No soportado en la tabla pivote. | Garantizado: line_total derivado mediante save(). |
| Restricciones de Integridad SQL | Limitado a unicidad de pares de llaves. | Extensible: Admite CheckConstraint numéricas (psale_amounts_valid). |
| Navegación en el ORM | sale.products.all() |
Estructurada: sale.lines.all() y product.sale_lines.all(). |
5.2 Inmutabilidad y Precio Histórico (unit_price)
Un error recurrente en sistemas de información es calcular el importe de las ventas del pasado navegando dinámicamente hacia el precio actual del catálogo (line.product.price).
| Enfoque de Modelado | Comportamiento ante Cambio en Product.price |
Consecuencia en el Negocio |
|---|---|---|
Navegación Dinámica (line.product.price) |
El subtotal de ventas pasadas se altera automáticamente al actualizar el catálogo. | Catastrófico: Corrupción retroactiva de estados financieros, descuadres contables y violaciones de auditoría fiscal. |
Snapshot Histórico ISS-06 (line.unit_price) |
El precio se congela en ProductSale.unit_price al registrar la transacción. |
Correcto: Inmutabilidad transaccional, trazabilidad financiera exacta y cumplimiento de normas auditables. |
Al requerir que ProductSale posea su propia columna unit_price, la aplicación garantiza que la transacción sea un reflejo fiel de las condiciones comerciales pactadas en el instante exacto de la operación.
5.3 Anulación Atómica y Manejo de Concurrencia (void())
La baja de una venta no debe realizarse mediante sentencias DELETE SQL, puesto que eliminar un registro contable borra la evidencia histórica. El método void() implementa una baja lógica alternando el estado del registro a INACTIVE, coordinando la restitución de inventario de forma atómica:
FLUJO DE EJECUCIÓN EN void():
Sale.void()
│
▼
[transaction.atomic()] ─── Abre bloque transaccional ACID
│
▼
[Sale.objects.select_for_update().get(pk=self.pk)] ─── Bloquea fila de la Venta
│
├── ¿sale.status == INACTIVE? ── SÍ ──> [RETURN (Idempotencia)]
│
NO
▼
[lines.select_related().select_for_update().order_by("product_id")]
│
▼ (Para cada línea activa)
[Product.objects.select_for_update().get(pk=line.product_id)] ─── Bloquea Producto
│
├── product.stock += line.quantity
├── product.save(update_fields=["stock", "updated_at"])
├── line.status = INACTIVE
└── line.save(update_fields=["status", "updated_at"])
│
▼
[sale.status = INACTIVE] ─── Inactiva la Cabecera
│
▼
[COMMIT BD] ─── Libera bloqueos pesimistas y consolida cambios
El análisis de este mecanismo destaca tres pilares de concurrencia:
- Aislamiento Pesimista:
select_for_update()emite sentencias SQLFOR UPDATE, forzando a cualquier otro proceso que intente modificar el stock o el estado de esos registros a esperar a que la transacción activa se complete. - Prevención de Deadlocks mediante Ordenamiento: Si la Venta A (con Productos 1 y 5) y la Venta B (con Productos 5 y 1) se anularan simultáneamente sin ordenamiento, la Venta A bloquearía el Producto 1 y esperaría el 5, mientras la Venta B bloquearía el Producto 5 y esperaría el 1, generando un interbloqueo mutuo. Aplicar
.order_by("product_id")exige que todas las peticiones adquieran los bloqueos pesimistas exactamente en la misma secuencia numérica, erradicando los deadlocks. - Idempotencia: Si el método
void()se ejecuta sobre una venta previamente anulada, la condiciónif sale.status == RecordStatus.INACTIVE:retorna de forma inmediata sin alterar nuevamente el stock, garantizando una operación segura ante reintentos.
Con los modelos y patrones definidos, el paso siguiente consiste en traducirlos a la capa de persistencia relacional mediante las migraciones de Django.
6. Comandos CLI, Migraciones y Secuencia de Persistencia
La traducción de los modelos declarados en Python hacia tablas relacionales físicas dentro de la base de datos se gestiona exclusivamente a través del subsistema de migraciones de Django. Esta metodología asegura que el esquema relacional evolucione de manera declarativa y sincronizada, previniendo la manipulación directa e inconsistente de scripts SQL DDL.
Para procesar la creación de los modelos Sale y ProductSale, se ejecutan las siguientes instrucciones en la terminal con el entorno .venv activo:
El desglose de esta secuencia CLI y sus efectos directos sobre el sistema se describen a continuación:
| Comando CLI | Acción Ejecutada | Archivos / Tablas Generados | Efecto en el Motor de Base de Datos |
|---|---|---|---|
python manage.py makemigrations sale |
Inspecciona apps/sale/models.py y detecta las nuevas entidades, campos y restricciones. |
Genera el archivo de migración apps/sale/migrations/0001_initial.py. |
Operación local de inspección. Prepara las instrucciones DDL en código Python sin modificar la BD. |
python manage.py migrate |
Lee las migraciones pendientes y ejecuta las instrucciones DDL sobre el motor configurado en DATABASES["default"]. |
Registra la migración en la tabla del sistema django_migrations. |
Ejecuta CREATE TABLE sales, CREATE TABLE product_sales, crea las claves foráneas, índices y la restricción Check SQL. |
La ejecución exitosa de los comandos de migración concluye con la creación física de las tablas relacionales de ventas e inventario.
7. Esquema Físico de Base de Datos Resultante
La ejecución de las migraciones traduce la sintaxis del ORM en estructuras relacionales concretas sobre las tablas físicas sales y product_sales. Ambas tablas implementan las convenciones explícitas de nombres (db_table, db_column), tipos de datos con precisión decimal y restricciones de integridad.
Tabla Física 1: sales
Representa la cabecera de las transacciones comerciales.
| Columna Física | Tipo de Dato BD | Nulidad / Defaults | Clave / Restricción | Origen en Modelo |
|---|---|---|---|---|
id |
BIGINT |
NOT NULL |
PRIMARY KEY (Auto-increment) |
Generado implícitamente (BigAutoField). |
client_id |
BIGINT |
NOT NULL |
FOREIGN KEY -> clients(id) (RESTRICT) |
client = models.ForeignKey(...) |
sale_date |
TIMESTAMP WITH TIME ZONE |
NOT NULL |
Ninguna | sale_date = models.DateTimeField() |
subtotal |
DECIMAL(12, 2) |
NOT NULL DEFAULT 0.00 |
CHECK (subtotal >= 0) |
subtotal = models.DecimalField(...) |
tax |
DECIMAL(12, 2) |
NOT NULL DEFAULT 0.00 |
CHECK (tax >= 0) |
tax = models.DecimalField(...) |
discounts |
DECIMAL(12, 2) |
NOT NULL DEFAULT 0.00 |
CHECK (discounts >= 0) |
discounts = models.DecimalField(...) |
total |
DECIMAL(12, 2) |
NOT NULL DEFAULT 0.00 |
CHECK (total >= 0) |
total = models.DecimalField(...) |
status |
VARCHAR(8) |
NOT NULL DEFAULT 'inactive' |
CHECK (status IN ('active', 'inactive')) |
Heredado de StoreLabModel. |
created_at |
TIMESTAMP WITH TIME ZONE |
NOT NULL |
Autogenerado al crear fila | Heredado de TimeStampedModel. |
updated_at |
TIMESTAMP WITH TIME ZONE |
NOT NULL |
Autogenerado al modificar fila | Heredado de TimeStampedModel. |
Tabla Física 2: product_sales
Representa el detalle de líneas de producto asociadas a cada venta.
| Columna Física | Tipo de Dato BD | Nulidad / Defaults | Clave / Restricción | Origen en Modelo |
|---|---|---|---|---|
id |
BIGINT |
NOT NULL |
PRIMARY KEY (Auto-increment) |
Generado implícitamente (BigAutoField). |
sale_id |
BIGINT |
NOT NULL |
FOREIGN KEY -> sales(id) (RESTRICT) |
sale = models.ForeignKey(...) |
product_id |
BIGINT |
NOT NULL |
FOREIGN KEY -> products(id) (RESTRICT) |
product = models.ForeignKey(...) |
quantity |
INTEGER |
NOT NULL |
CHECK (quantity >= 1) |
quantity = PositiveIntegerField(...) |
unit_price |
DECIMAL(12, 2) |
NOT NULL |
CHECK (unit_price >= 0) |
unit_price = DecimalField(...) |
line_total |
DECIMAL(12, 2) |
NOT NULL |
CHECK (line_total >= 0) |
line_total = DecimalField(...) |
status |
VARCHAR(8) |
NOT NULL DEFAULT 'inactive' |
CHECK (status IN ('active', 'inactive')) |
Heredado de StoreLabModel. |
created_at |
TIMESTAMP WITH TIME ZONE |
NOT NULL |
Autogenerado al crear fila | Heredado de TimeStampedModel. |
updated_at |
TIMESTAMP WITH TIME ZONE |
NOT NULL |
Autogenerado al modificar fila | Heredado de TimeStampedModel. |
- Precisión sobre Nomenclatura de Restricciones: Las restricciones de estado
sales_status_validyproduct_sales_status_validson generadas a partir del método estáticoStoreLabModel.status_constraint(), definido enapps/common/models.pydurante la unidad ISS-04. Dichas restricciones conviven con las Check Constraints explícitas de importessales_amounts_non_negypsale_amounts_validsobre el motor relacional.
El conocimiento del esquema físico y sus restricciones permite catalogar los antipatrones más comunes cometidos durante la fase de modelado y establecer sus soluciones correctivas.
8. Matriz de Errores Frecuentes y Antipatrones de Diseño
Durante la implementación de arquitecturas de datos para transacciones, es común incurrir en decisiones de diseño desafortunadas que comprometen la integridad del sistema en producción. Identificar estos antipatrones y comprender su impacto transaccional es fundamental para afianzar el criterio técnico de ingeniería.
| Antipatrón / Error Frecuente | Causa Raíz en Código / ORM | Impacto Transaccional / Riesgo | Solución Correctiva ISS-06 |
|---|---|---|---|
| 1. M2M Directo sin Tabla Intermedia Explícita | Declarar products = models.ManyToManyField(Product) en el modelo Sale. |
Imposibilidad de almacenar la cantidad por ítem, el precio de venta congelado y el subtotal de línea. Destrucción del dominio comercial. | Crear el modelo explícito ProductSale con sus propias FKs a Sale y Product, asignando related_name="lines". |
| 2. Desincronización de Totales de Línea | Permitir el cálculo de line_total externamente o usar operaciones Float sin pasar por quantize(). |
Pérdida o ganancia de centavos por imprecisión binaria; inconsistencia entre el total almacenado y la suma real de ítems. | Sobreescribir save() en ProductSale forzando self.line_total = (self.unit_price * self.quantity).quantize(Decimal("0.01")). |
| 3. Anulación de Venta Insegura o Parcial | Implementar void() sin transaction.atomic() o iterar líneas sin aplicar select_for_update(). |
Condiciones de carrera en concurrencia; restituciones dobles o parciales de stock; posibles deadlocks entre hilos concurrentes. | Envolver void() en transaction.atomic(), usar select_for_update(), e imponer .order_by("product_id") en las líneas. |
| 4. Navegación Dinámica al Precio del Catálogo | Omitir unit_price en el detalle y calcular subtotales navegando a line.product.price. |
Modificaciones futuras en el precio de un catálogo alteran retroactivamente los balances e impuestos de ventas de años pasados. | Definir la columna unit_price en ProductSale para congelar el Snapshot monetario exacto en el momento del registro. |
La prevención de estos errores se valida objetivamente en el proyecto mediante el cumplimiento de los Criterios de Aceptación y el paso del GATE correspondiente.
9. Criterios de Aceptación, Verificación, Evidencias y Tabla GATE
En el entorno de desarrollo de StoreLab, cada hito técnico requiere ser formalmente verificado y respaldado por una evidencia objetiva antes de considerar superada la unidad y autorizar el avance hacia el siguiente ISS.
Criterios de Aceptación Técnicos del ISS-06
AC-06-01: Existen las tablas físicassalesyproduct_salesen la base de datos, creadas mediante la migración del ORM.AC-06-02: Las relaciones de clave foránea (ForeignKey) enSaleyProductSaleutilizan explícitamente la reglaon_delete=models.RESTRICT.AC-06-03: El atributoline_totaldeProductSalese calcula automáticamente en el métodosave()derivado del producto entre la cantidad y el precio unitario (quantity * unit_price).AC-06-04: El modeloSaleno hace uso del campoManyToManyFielddirecto hacia la entidadProduct.
Secuencia de Comandos CLI y Verificación de Pruebas
Para verificar de forma automatizada los criterios de aceptación y evaluar la preservación del precio histórico, se ejecuta el siguiente comando en la terminal:
El test de verificación ejecuta una venta inicial con un producto a un precio determinado, altera posteriormente el precio de dicho producto en el catálogo mediante Product.objects.filter(...).update(price=...), y verifica que la línea de la venta previamente registrada mantenga su valor unit_price inalterado.
Tabla GATE Oficial del ISS-06
| Criterio de Aceptación (AC) | Verificación Realizada | Evidencia Vinculada | Resultado (PASS/FAIL) |
|---|---|---|---|
AC-06-01 |
Inspección de migración ejecutada sobre la base de datos relacional. | EVI-06-01: Migración sale.0001_initial aplicada correctamente en PostgreSQL / motor activo. |
PASS |
AC-06-02 |
Inspección sintáctica de los parámetros on_delete en apps/sale/models.py. |
EVI-06-01: Definición de modelos confirmada con on_delete=models.RESTRICT. |
PASS |
AC-06-03 |
Ejecución de la suite de pruebas unitarias sobre el calculador save() en ProductSale. |
EVI-06-02: Test de totales y precisión decimal PASS. |
PASS |
AC-06-04 |
Inspección directa del código fuente del modelo Sale en apps/sale/models.py. |
EVI-06-01: Ausencia total de ManyToManyField hacia Product confirmada. |
PASS |
Con los criterios técnicos verificados y la tabla GATE aprobada con resultado PASS, el estudiante se encuentra preparado para sostener la defensa oral técnica de la unidad.
10. Cuestionario de Defensa Oral Técnica y Evaluación Formativa
La defensa oral técnica exige justificar las decisiones de diseño adoptadas en la unidad, evaluando alternativas descartadas y demostrando un conocimiento riguroso del comportamiento interno del ORM y la base de datos relacional.
Pregunta 1
- Pregunta de Defensa: ¿Cuál es la razón técnica y contable por la cual se prohíbe el uso de una relación
ManyToManyFielddirecta entreSaleyProduct, exigiendo en su lugar el modelo intermedioProductSale? - Justificación Pedagógico-Técnica: Evalúa la capacidad del estudiante para distinguir entre abstracciones conceptuales simples y las necesidades verdaderas de la capa de persistencia en dominios financieros e inventariables.
- Respuesta de Nivel Experto (Grounded): Una relación
ManyToManyFielddirecta sin modelo explícito delega a Django la creación de una tabla pivote oculta que únicamente almacena las claves foráneassale_idyproduct_id. Este esquema es inviable para una arquitectura comercial porque no permite asociar datos propios de la transacción, como la cantidad vendida (quantity), el precio unitario congelado (unit_price) y el subtotal de la línea (line_total).ProductSalese declara como un modelo explícito para materializar la entidad intermedia de detalle, permitiendo congelar el Snapshot del precio histórico e imponer Check Constraints numéricas directas en la base de datos.
Pregunta 2
- Pregunta de Defensa: En el método
void()de la entidadSale, ¿cuál es el propósito de combinartransaction.atomic()conselect_for_update()y la instrucción.order_by("product_id")al iterar las líneas de la venta? - Justificación Pedagógico-Técnica: Mide la comprensión de las primitivas de concurrencia en bases de datos relacionales y la habilidad de diseñar algoritmos inmunes a condiciones de carrera (race conditions) e interbloqueos (deadlocks).
- Respuesta de Nivel Experto (Grounded):
transaction.atomic()delimita un bloque transaccional ACID que garantiza que la devolución del stock de los productos y la inactivación de la venta se ejecuten de forma indivisible.select_for_update()aplica un bloqueo pesimistaFOR UPDATEen SQL sobre las filas seleccionadas, impidiendo que otras peticiones concurrentes alteren dicho inventario. Por su parte,.order_by("product_id")impone que los bloqueos pesimistas sobre los productos se adquieran siempre en un orden secuencial estricto; esto erradica la posibilidad de interbloqueos (deadlocks) en la base de datos si dos ventas con los mismos productos intentan anularse simultáneamente desde hilos opuestos.
Pregunta 3
- Pregunta de Defensa: ¿Por qué es obligatorio utilizar el método
quantize(Decimal("0.01"))dentro de la sobreescritura del métodosave()en el modeloProductSale? - Justificación Pedagógico-Técnica: Valida el conocimiento práctico sobre la representación binaria de datos numéricos y el rigor necesario para evitar descuadres por redondeo en auditorías financieras.
- Respuesta de Nivel Experto (Grounded): Las operaciones aritméticas sobre números decimales pueden generar resultados con una precisión superior a la soportada por la columna física en SQL (
DECIMAL(12, 2)). El métodoquantize(Decimal("0.01"))aplica un redondeo estricto al segundo dígito decimal antes de invocar asuper().save(). Esto garantiza que el atributoline_totalmantenga una precisión monetaria exacta, alineada con las reglas contables e inmune a las inconsistencias de punto flotante.
Pregunta 4
- Pregunta de Defensa: ¿Qué comportamiento desencadena la regla
on_delete=models.RESTRICTen las claves foráneas deSaleyProductSale, y en qué se diferencia deCASCADEySET_NULL? - Justificación Pedagógico-Técnica: Evalúa el dominio sobre los mecanismos de integridad referencial relacional y la protección de datos históricos frente a eliminaciones físicas.
- Respuesta de Nivel Experto (Grounded):
on_delete=models.RESTRICTimpide la eliminación física de un registro padre (como unCliento unProduct) si existen registros hijos vinculados a él (como unaSaleo unProductSale), lanzando una excepcióndjango.db.models.deletion.RestrictedError. Se diferencia deCASCADEen que este último borraría automáticamente en cascada la venta y sus detalles destruyendo la auditoría financiera; y se diferencia deSET_NULLen que este último dejaría las ventas huérfanas conclient_id = NULL, violando la regla de negocio que exige que toda venta pertenezca obligatoriamente a un cliente identificado.
Pregunta 5
- Pregunta de Defensa: ¿Por qué el archivo
apps/sale/managers.pydefine la claseSaleManagerextendiendo demodels.Manager.from_queryset(SaleQuerySet)y cuál es el alcance de este módulo en la unidad ISS-06? - Justificación Pedagógico-Técnica: Examina la preparación arquitectónica del estudiante para construir capas de consulta extensibles dentro de Django ORM, respetando los límites del progreso incremental del proyecto.
- Respuesta de Nivel Experto (Grounded): La construcción
models.Manager.from_queryset(SaleQuerySet)le permite a Django proxyar automáticamente todos los métodos públicos definidos enSaleQuerySethacia el objetoSaleManager, haciendo accesible la interfaz de consulta tanto desde el gestor (Sale.objects) como en el encadenamiento de QuerySets. En la unidad ISS-06,SaleQuerySetySaleManagerse establecen intencionalmente como estructuras base mínimas (pass) para garantizar el desacoplamiento arquitectónico; esto prepara la infraestructura que alojará operaciones transaccionales complejas de nivel superior, como el método de registro atómicoSale.objects.register(), asignado formalmente a la unidad ISS-11.
Navegación de la ruta: ← ISS-05 · 🛠 Construir · ↑ Ruta Django · → ISS-06 · 🛠 Construir