Saltar a contenido

📚 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.

⛶ Ver presentación completa ⬇ Archivo editable (.pptx)

20 diapositivas · se visualiza 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

Infografía de la unidad

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) desde StoreLabModel.
  • 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 de SaleQuerySet mediante models.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 modelo Sale un 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 entidad Product e 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:

  1. Creación del módulo de managers (apps/sale/managers.py): Se utiliza el comando cat para generar el archivo de managers que aloja SaleQuerySet y SaleManager:
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
  1. Sobreescritura de los modelos (apps/sale/models.py): Se reemplaza el contenido por defecto del archivo models.py mediante la redirección del flujo de comandos, inyectando la definición completa de las entidades de negocio Sale y ProductSale, junto con la importación de Client, Product, StoreLabModel y SaleManager:
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 Manager objects.
  • 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.objects utilice 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 modelo Sale.

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. Aplica on_delete=models.RESTRICT para bloquear el borrado físico del cliente si tiene ventas, asigna el nombre de columna física client_id mediante db_column y establece el acceso inverso client.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.
  • 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). DecimalField se 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.
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 Constraint sales_amounts_non_neg, exigiendo que subtotal, tax, discounts y total sean 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 UPDATE sobre ellas y las ordena numéricamente por product_id.
    • POR QUÉ EXISTE $\rightarrow$ El ordenamiento estricto por product_id es 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_sales y el inventario en products.
  • 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_fields provoca que Django emita una sentencia UPDATE completa con todas las columnas del modelo. Si un usuario administrativo modifica concurrentemente el nombre o la descripción del producto, una actualización global sin update_fields sobreescribirí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)
  • sale y product (ForeignKey):
    • QUÉ ES $\rightarrow$ Relaciones de clave foránea hacia los modelos Sale y Product.
    • 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 (lines y sale_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 sales y products.
  • unit_price y line_total (DecimalField):
    • QUÉ ES $\rightarrow$ Campos numéricos decimales para almacenar precios e importes.
    • QUÉ HACE $\rightarrow$ unit_price congela el precio unitario pactado al momento de vender. line_total almacena 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() de ProductSale.
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.
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_total multiplicando la cantidad por el precio unitario y aplicando redondeo exacto a dos decimales con quantize() antes de invocar a super().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_total de la base de datos y los estándares de precisión monetaria.

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 ManyToManyField está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:

  1. Aislamiento Pesimista: select_for_update() emite sentencias SQL FOR 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.
  2. 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.
  3. Idempotencia: Si el método void() se ejecuta sobre una venta previamente anulada, la condición if 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:

python manage.py makemigrations sale
python manage.py migrate

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_valid y product_sales_status_valid son generadas a partir del método estático StoreLabModel.status_constraint(), definido en apps/common/models.py durante la unidad ISS-04. Dichas restricciones conviven con las Check Constraints explícitas de importes sales_amounts_non_neg y psale_amounts_valid sobre 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ísicas sales y product_sales en la base de datos, creadas mediante la migración del ORM.
  • AC-06-02: Las relaciones de clave foránea (ForeignKey) en Sale y ProductSale utilizan explícitamente la regla on_delete=models.RESTRICT.
  • AC-06-03: El atributo line_total de ProductSale se calcula automáticamente en el método save() derivado del producto entre la cantidad y el precio unitario (quantity * unit_price).
  • AC-06-04: El modelo Sale no hace uso del campo ManyToManyField directo hacia la entidad Product.

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:

python manage.py test apps.sale

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 ManyToManyField directa entre Sale y Product, exigiendo en su lugar el modelo intermedio ProductSale?
  • 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 ManyToManyField directa sin modelo explícito delega a Django la creación de una tabla pivote oculta que únicamente almacena las claves foráneas sale_id y product_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). ProductSale se 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 entidad Sale, ¿cuál es el propósito de combinar transaction.atomic() con select_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 pesimista FOR UPDATE en 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étodo save() en el modelo ProductSale?
  • 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étodo quantize(Decimal("0.01")) aplica un redondeo estricto al segundo dígito decimal antes de invocar a super().save(). Esto garantiza que el atributo line_total mantenga 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.RESTRICT en las claves foráneas de Sale y ProductSale, y en qué se diferencia de CASCADE y SET_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.RESTRICT impide la eliminación física de un registro padre (como un Client o un Product) si existen registros hijos vinculados a él (como una Sale o un ProductSale), lanzando una excepción django.db.models.deletion.RestrictedError. Se diferencia de CASCADE en que este último borraría automáticamente en cascada la venta y sus detalles destruyendo la auditoría financiera; y se diferencia de SET_NULL en que este último dejaría las ventas huérfanas con client_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.py define la clase SaleManager extendiendo de models.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 en SaleQuerySet hacia el objeto SaleManager, haciendo accesible la interfaz de consulta tanto desde el gestor (Sale.objects) como en el encadenamiento de QuerySets. En la unidad ISS-06, SaleQuerySet y SaleManager se 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ómico Sale.objects.register(), asignado formalmente a la unidad ISS-11.

Navegación de la ruta: ← ISS-05 · 🛠 Construir · ↑ Ruta Django · → ISS-06 · 🛠 Construir