📚 Unidad ISS-11 · Registro de venta con APIView — capa 🧠 APRENDER
🛠 Construir este bloque → · ✅ Condiciones de cierre (GATE) · 📝 Evaluación
Capa Página Para qué 🧠 Aprender esta página comprender, explicar y relacionar 🛠 Construir Registro de venta con APIView ejecutar, programar y verificar ✅ GATE Cierre de la unidad condición para pasar al bloque siguiente
Mapa de correspondencias. Cada fila enlaza el mismo tema en las dos capas; los enlaces apuntan a secciones reales del material.
| Tema | 🧠 Aprender (esta página) | 🛠 Construir (ISS técnico) |
|---|---|---|
| Objetivo | A. Objetivos y Requisitos de ISS-11 | Objetivo |
| Recorrido | C. Estructura de Archivos y Paquetes | Construcción |
| Cierre | J. Criterios de Aceptación (AC-11-01 a AC-11-05) | Criterios de aceptación · GATE |
| Evaluación | L. Cuestionario de Defensa Oral Técnica | GATE |
🖥 Presentación del ISS
Presentación de la unidad. Diapositivas de ISS-11 — Registro de venta con APIView (11 diapositivas). Se visualiza aquí, dentro del sitio.
Infografía
Guía de Estudio: ISS-11 — Registro de Venta con APIView y Transaccionalidad ACID en Django
A. Objetivos y Requisitos de ISS-11
Objetivo Principal
Registrar una venta comercial en la plataforma StoreLab garantizando que toda la operación se ejecute bajo una única transacción atómica ACID (Atomicity, Consistency, Isolation, Durability). La transacción debe involucrar de forma indivisible el estado del cliente, la validación del catálogo, el congelamiento del precio histórico, la verificación y descuento del inventario de productos, y el cálculo estricto de subtotales, impuestos y totales. Si cualquier paso de la cadena falla, la base de datos debe revertir por completo los cambios (rollback), impidiendo la creación de ventas parciales o el descuento erróneo de stock.
Requisitos Funcionales y Técnicos
- Unicidad Atómica (ACID): El registro de la cabecera de venta (
Sale) y sus líneas compuestas (ProductSale) debe ser totalmente atómico respecto al descuento del inventario (Product.stock). - Congelamiento de Precio Histórico: El precio unitario de cada producto vendido (
unit_price) debe tomarse del valor actual del catálogo en el momento exacto de la transacción y almacenarse de forma persistente en la columnaProductSale.unit_price. Cambios futuros en la lista de precios del catálogo no deben alterar las ventas ya realizadas. - Validación de Entidades Activas: Se debe garantizar mediante bloqueos pesimistas que tanto el cliente (
Client) como cada producto (Product) y su respectivo tipo de producto (ProductType) se encuentren en estadoRecordStatus.ACTIVE(active). - Prevención de Concurrencia e Interbloqueos (
Deadlocks): El bloqueo pesimista sobre los productos debe realizarse mediante un ordenamiento determinista por clave primaria (order_by("id")). - Ajuste de Totales: La venta se registra con importes calculados en memoria, redondeados a dos decimales con precisión monetaria (
quantize(Decimal("0.01"))). El descuento global (discounts) no puede ser negativo ni superior al subtotal generado. - Rechazo y Reversión: Si la cantidad solicitada supera el stock disponible o si ocurre un fallo en la persistencia del inventario, la API debe responder con un código HTTP
400 Bad Requesto generar unrollbacktotal sin dejar registros huérfanos.
B. Conceptos Esenciales y Vocabulario Técnico
| Concepto / Término | Definición y Función en ISS-11 |
|---|---|
APIView |
Clase base de Django REST Framework (DRF) para vistas basadas en clases. Ofrece control fino sobre los métodos HTTP (POST), descartando la lógica genérica de CRUD CRUD automático de ModelViewSet cuando la operación requiere un flujo complejo no estándar. |
transaction.atomic() |
Administrador de contexto y decorador del ORM de Django que delimita un bloque de transacción SQL. Garantiza que todas las consultas de lectura/escritura dentro de su alcance se confirmen (commit) o se reviertan (rollback) como un bloque único. |
select_for_update() |
Cláusula del ORM que traduce a una sentencia SQL SELECT ... FOR UPDATE. Aplica un bloqueo pesimista a nivel de fila sobre los registros leídos en la base de datos, impidiendo que otras transacciones concurrentes los modifiquen hasta finalizar la transacción. |
quantize() |
Método de la librería estándar decimal.Decimal de Python. Aplica redondeo aritmético exacto con formato monetario estricto (ej. Decimal("0.01")), evitando las imprecisiones de los tipos de datos en coma flotante (float). |
order_by('id') |
Método de ordenamiento del ORM de Django. En el contexto de bloqueos pesimistas, forzar el orden de adquisición de bloqueos por la clave primaria (id) previene interbloqueos cruzados (deadlocks) entre hilos o procesos concurrentes. |
update_fields |
Argumento del método .save() en modelos Django. Limita la instrucción SQL UPDATE únicamente a las columnas especificadas en la lista, incrementando la eficiencia y reduciendo el riesgo de sobrescribir datos modificados por procesos paralelos. |
DjangoValidationError |
Excepción django.core.exceptions.ValidationError. Disparada por la capa de dominio/modelos/managers para indicar que una regla de negocio o invariante del sistema ha sido violada. |
serializers.ValidationError |
Excepción rest_framework.serializers.ValidationError. Utilizada por DRF para estructurar respuestas HTTP de error 400 Bad Request en formato JSON estandarizado para el cliente de la API. |
AllowAny |
Clase de permiso de DRF que concede acceso público abierto a un endpoint sin requerir cabeceras de autenticación (utilizado como esquema de acceso OPEN durante la Fase I). |
APITestCase |
Clase base de pruebas unitarias provista por DRF. Hereda de django.test.TestCase e integra un cliente de pruebas HTTP (self.client) configurado para consumir y enviar JSON. |
unittest.mock.patch |
Herramienta de pruebas de Python para interceptar y reemplazar objetos o métodos en tiempo de ejecución. Permite simular fallos controlados (ej. forzar una excepción en Product.save). |
C. Estructura de Archivos y Paquetes
El desarrollo del registro transaccional en ISS-11 implica la actualización y creación de varios módulos dentro del paquete apps/sale/ y la vinculación en el enrutamiento raíz del proyecto:
storelab/
├── apps/
│ └── sale/
│ ├── managers.py # [PARCHE] Implementación de SaleQuerySet.register con la lógica atómica
│ ├── serializers.py # [ACTUALIZACIÓN] Incorporación de SaleItemSerializer y SaleRegisterSerializer
│ ├── views.py # [CREACIÓN] Implementación de SaleRegisterAPIView y SaleCollectionAPIView
│ ├── urls.py # [CREACIÓN] Enrutamiento explícito para POST /api/sales/
│ └── tests.py # [ACTUALIZACIÓN] Suite de pruebas unitarias transaccionales SaleTransactionTests
└── config/
└── urls.py # [PARCHE] Inclusión de las URLs de apps.sale bajo el prefijo api/
1. Parche en apps/sale/managers.py
Se extiende la clase SaleQuerySet agregando el método del dominio register(), el cual orquesta la validación, el bloqueo pesimista y el guardado de la venta dentro de un bloque transaction.atomic().
2. Actualización de apps/sale/serializers.py
Se añaden SaleItemSerializer (para parsear los elementos del cuerpo JSON) y SaleRegisterSerializer (para la validación de entrada HTTP y la traducción de excepciones de Django hacia DRF).
3. Creación de apps/sale/views.py
Se implementan dos clases basadas en APIView:
* SaleRegisterAPIView: Procesa la petición, ejecuta la validación del serializador, invoca el método del manager y retorna la respuesta serializada enriquecida.
* SaleCollectionAPIView: Delega el manejo del objeto request hacia SaleRegisterAPIView.
4. Creación de apps/sale/urls.py y parche en config/urls.py
Se expone la ruta sales/ conectada a SaleCollectionAPIView.as_view() bajo el nombre sale-collection, e incluida dentro del archivo de configuración global config/urls.py mediante path("api/", include("apps.sale.urls")).
D. Explicación por Bloques Semánticos de SaleQuerySet.register
Ubicación: apps/sale/managers.py
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import models, transaction
from apps.common.status import RecordStatus
from apps.product.models import Product
class SaleQuerySet(models.QuerySet):
def register(self, *, client, sale_date, items, discounts):
# BLOQUE 1: Validaciones preliminares de entrada
if not items:
raise ValidationError({"items": ["La venta requiere al menos un producto."]})
discounts = Decimal(discounts).quantize(Decimal("0.01"))
if discounts < 0:
raise ValidationError({"discounts": ["El descuento no puede ser negativo."]})
quantities = {}
for item in items:
quantity = int(item["quantity"])
if quantity < 1:
raise ValidationError({"items": ["La cantidad debe ser al menos 1."]})
product_id = item["product_id"]
quantities[product_id] = quantities.get(product_id, 0) + quantity
# BLOQUE 2: Apertura del contexto atómico y bloqueo pesimista del Cliente
with transaction.atomic():
locked_client = client.__class__.objects.select_for_update().get(pk=client.pk)
if locked_client.status != RecordStatus.ACTIVE:
raise ValidationError({"client_id": ["El cliente debe estar active."]})
# BLOQUE 3: Bloqueo pesimista de Productos y prevención de deadlocks
locked_products = {
product.id: product
for product in Product.objects.select_for_update()
.select_related("product_type")
.filter(pk__in=quantities)
.order_by("id")
}
if len(locked_products) != len(quantities):
raise ValidationError({"items": ["Hay productos que no existen."]})
# BLOQUE 4: Verificaciones de existencia, estados activos y suficiencia de stock
for product_id, quantity in quantities.items():
product = locked_products[product_id]
if product.status != RecordStatus.ACTIVE:
raise ValidationError(
{"items": [f"El producto {product.name} no está active."]}
)
if product.product_type.status != RecordStatus.ACTIVE:
raise ValidationError(
{"items": [f"El tipo de {product.name} no está active."]}
)
if product.stock < quantity:
raise ValidationError(
{"items": [f"Stock insuficiente para {product.name}."]}
)
# BLOQUE 5: Creación de la instancia Sale borrador
sale = self.model(
client=locked_client,
sale_date=sale_date,
subtotal=Decimal("0.00"),
tax=Decimal("0.00"),
discounts=discounts,
total=Decimal("0.00"),
status=RecordStatus.INACTIVE,
)
sale.save()
# BLOQUE 6: Iteración de ítems, congelamiento de precio histórico y creación de detalles
subtotal = Decimal("0.00")
for item in items:
product = locked_products[item["product_id"]]
unit_price = product.price
quantity = int(item["quantity"])
line_total = (unit_price * quantity).quantize(Decimal("0.01"))
sale.lines.create(
product=product,
quantity=quantity,
unit_price=unit_price,
line_total=line_total,
status=RecordStatus.ACTIVE,
)
subtotal += line_total
# BLOQUE 7: Validación de montos globales y activación de la Sale
subtotal = subtotal.quantize(Decimal("0.01"))
if discounts > subtotal:
raise ValidationError(
{"discounts": ["El descuento no puede superar el subtotal."]}
)
tax = Decimal("0.00")
total = (subtotal + tax - discounts).quantize(Decimal("0.01"))
sale.subtotal = subtotal
sale.tax = tax
sale.discounts = discounts
sale.total = total
sale.status = RecordStatus.ACTIVE
sale.save(
update_fields=[
"subtotal",
"tax",
"discounts",
"total",
"status",
"updated_at",
]
)
# BLOQUE 8: Descuento del inventario en Product.stock
for product_id, quantity in quantities.items():
product = locked_products[product_id]
product.stock -= quantity
product.save(update_fields=["stock", "updated_at"])
return sale
Análisis Detallado de los Bloques Semánticos
- Validaciones preliminares de entrada: Verifica en memoria que la lista de ítems no esté vacía, cuantifica los descuentos a dos decimales, impide descuentos negativos, consolida las cantidades totales requeridas por producto en el diccionario
quantitiesy asegura que cada línea solicite al menos una unidad (quantity >= 1). - Apertura de
transaction.atomic()y bloqueo del cliente: Inicia la transacción SQL. Ejecutaselect_for_update()sobre la tablaclientspara la clave primaria del comprador. Valida que el cliente esté en estadoRecordStatus.ACTIVE. - Bloqueo pesimista de productos y prevención de deadlocks: Recupera todos los productos involucrados aplicando
.select_for_update().select_related("product_type").order_by("id"). El métodoorder_by("id")adquiere los bloqueos filas en un orden estricto de menor a mayor ID, lo que previene de forma absoluta interbloqueos cruzados (deadlocks) bajo concurrencia. - Verificación de reglas de dominio e inventario: Itera sobre el mapa de productos bloqueados, verificando que todos los ID existan, que los productos y sus tipos asociados estén activos (
status == ACTIVE) y queproduct.stock >= quantity. - Creación de la venta borrador (
INACTIVE): Persiste inicialmente la cabeceraSaleconstatus = INACTIVEy montos en cero. Si el proceso falla más adelante, esta cabecera borrador se revierte en elrollback. - Congelamiento de precio e inserción de líneas (
ProductSale): Para cada ítem, extraeproduct.priceen ese instante (unit_price), calcula elline_totalindividual conquantize()y crea la línea en la tablaproduct_salesvinculada a la venta con estadoACTIVE. - Cálculo de montos finales y activación: Acumula el subtotal final, valida que el descuento no supere el subtotal (
discounts <= subtotal), calcula eltotal(subtotal + tax - discounts), pasa la venta astatus = ACTIVEy guarda la cabecera optimizando la consulta conupdate_fields. - Descuento de stock en el catálogo: Modifica
product.stockrestando la cantidad consolidada y persiste de forma explícita medianteproduct.save(update_fields=["stock", "updated_at"]). Retorna la venta registrada.
E. Explicación por Bloques Semánticos de SaleRegisterSerializer
Ubicación: apps/sale/serializers.py
from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from apps.client.models import Client
from apps.product.models import Product
from apps.sale.models import Sale
class SaleItemSerializer(serializers.Serializer):
product_id = serializers.PrimaryKeyRelatedField(
queryset=Product.objects.all(),
source="product",
)
quantity = serializers.IntegerField(min_value=1)
class SaleRegisterSerializer(serializers.Serializer):
client_id = serializers.PrimaryKeyRelatedField(
queryset=Client.objects.all(),
source="client",
)
sale_date = serializers.DateTimeField()
discounts = serializers.DecimalField(
max_digits=12,
decimal_places=2,
min_value=Decimal("0.00"),
required=False,
default=Decimal("0.00"),
)
items = SaleItemSerializer(many=True, allow_empty=False)
def create(self, validated_data):
items = [
{"product_id": item["product"].id, "quantity": item["quantity"]}
for item in validated_data["items"]
]
try:
return Sale.objects.register(
client=validated_data["client"],
sale_date=validated_data["sale_date"],
discounts=validated_data["discounts"],
items=items,
)
except DjangoValidationError as exc:
if getattr(exc, "message_dict", None):
raise serializers.ValidationError(exc.message_dict) from exc
raise serializers.ValidationError(exc.messages) from exc
Análisis del Serializador
- Estructura de Entrada: Utiliza
PrimaryKeyRelatedFieldenclient_idyproduct_idmapeando hacia los modelos correspondientes mediantesource. Mantiene la restricción de que la lista de ítems no puede estar vacía (allow_empty=False). - Método
create(): Convierte la lista parseada por DRF a un diccionario básico compuesto porproduct_id(entero) yquantity. Delega la responsabilidad de la transacción de negocio invocando directamente aSale.objects.register(...). - Traducción de Excepciones: Atrapa las excepciones
DjangoValidationErrorlanzadas desde el ORM o el manager. Si la excepción contiene un diccionario de errores (message_dict), lo re-lanza como unserializers.ValidationError(exc.message_dict). Esto le indica a DRF que construya un cuerpo HTTP JSON400 Bad Requestconservando la estructura exacta de los campos validados.
F. Explicación por Bloques Semánticos de apps/sale/views.py y apps/sale/urls.py
Vistas HTTP: apps/sale/views.py
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.sale.models import Sale
from apps.sale.serializers import SaleRegisterSerializer, SaleSerializer
class SaleRegisterAPIView(APIView):
permission_classes = [AllowAny]
def post(self, request):
serializer = SaleRegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
sale = serializer.save()
sale = Sale.objects.prefetch_related("lines__product").get(pk=sale.pk)
return Response(SaleSerializer(sale).data, status=status.HTTP_201_CREATED)
class SaleCollectionAPIView(APIView):
permission_classes = [AllowAny]
def post(self, request, *args, **kwargs):
handler = SaleRegisterAPIView()
handler.request = request
handler.format_kwarg = None
handler.args = ()
handler.kwargs = {}
return handler.post(request)
Análisis de las Vistas
SaleRegisterAPIView: Recibe los datos HTTP, valida la estructura medianteserializer.is_valid(raise_exception=True)e invocaserializer.save(). Una vez completada la transacción, recupera el objeto registrado aplicando.prefetch_related("lines__product")para evitar consultas N+1 al serializar la respuesta. Devuelve la representación completa con el serializador de lecturaSaleSerializery un estado HTTP201 Created.SaleCollectionAPIView: Actúa como punto de entrada desacoplado para la colección de ventas (/api/sales/). Asigna el objetorequestentrante e invoca internamente el métodopostdeSaleRegisterAPIView.
Configuración de Enrutamiento: apps/sale/urls.py y config/urls.py
# apps/sale/urls.py
from django.urls import path
from apps.sale.views import SaleCollectionAPIView
urlpatterns = [
path("sales/", SaleCollectionAPIView.as_view(), name="sale-collection"),
]
# config/urls.py (Extracto de modificación)
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("apps.client.urls")),
path("api/", include("apps.product.urls")),
path("api/", include("apps.sale.urls")), # Parche ISS-11
]
G. Pruebas Unitarias de Transacción en apps/sale/tests.py
Ubicación: apps/sale/tests.py
from decimal import Decimal
from unittest.mock import patch
from django.utils import timezone
from rest_framework.test import APITestCase
from apps.client.models import Client
from apps.common.status import RecordStatus
from apps.product.models import Product, ProductType
from apps.sale.models import ProductSale, Sale
class SaleTransactionTests(APITestCase):
def setUp(self):
self.buyer = Client.objects.create(name="Comprador", status=RecordStatus.ACTIVE)
product_type = ProductType.objects.create(name="Abarrote", status=RecordStatus.ACTIVE)
self.water = Product.objects.create(
product_type=product_type,
name="Agua",
price=Decimal("10.00"),
stock=5,
status=RecordStatus.ACTIVE,
)
self.bread = Product.objects.create(
product_type=product_type,
name="Pan",
price=Decimal("4.50"),
stock=2,
status=RecordStatus.ACTIVE,
)
def _payload(self, items, discounts="0.00"):
return {
"clientId": self.buyer.id,
"saleDate": timezone.now().isoformat(),
"discounts": discounts,
"items": items,
}
def test_register_snapshots_price_and_discounts_stock(self):
response = self.client.post(
"/api/sales/",
self._payload(
[
{"productId": self.water.id, "quantity": 2},
{"productId": self.bread.id, "quantity": 1},
],
discounts="1.50",
),
format="json",
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(response.data["tax"], "0.00")
self.assertEqual(response.data["subtotal"], "24.50")
self.assertEqual(response.data["total"], "23.00")
self.assertEqual(response.data["status"], "active")
water_line = next(
line for line in response.data["lines"] if line["product"] == self.water.id
)
self.assertEqual(water_line["unit_price"], "10.00")
self.water.refresh_from_db()
self.bread.refresh_from_db()
self.assertEqual(self.water.stock, 3)
self.assertEqual(self.bread.stock, 1)
def test_insufficient_stock_rolls_back(self):
response = self.client.post(
"/api/sales/",
self._payload([{"productId": self.bread.id, "quantity": 3}]),
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Sale.objects.count(), 0)
self.bread.refresh_from_db()
self.assertEqual(self.bread.stock, 2)
def test_stock_failure_rolls_back_the_sale(self):
original = Product.save
def fail_stock(product, *args, **kwargs):
update_fields = kwargs.get("update_fields")
if update_fields and "stock" in update_fields:
raise RuntimeError("fallo de stock")
return original(product, *args, **kwargs)
with patch.object(Product, "save", fail_stock):
with self.assertRaises(RuntimeError):
self.client.post(
"/api/sales/",
self._payload([{"productId": self.water.id, "quantity": 1}]),
format="json",
)
self.assertEqual(Sale.objects.count(), 0)
self.assertEqual(ProductSale.objects.count(), 0)
def test_inactive_client_is_rejected(self):
self.buyer.status = RecordStatus.INACTIVE
self.buyer.save()
response = self.client.post(
"/api/sales/",
self._payload([{"productId": self.water.id, "quantity": 1}]),
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Sale.objects.count(), 0)
Análisis de las Pruebas Unitarias
test_register_snapshots_price_and_discounts_stock: Verifica que una venta exitosa calcula correctamente los totales ($2 \times 10.00 + 1 \times 4.50 - 1.50 = 23.00$), asigna el estado activo, congela el precio histórico en $10.00$ y descuenta el stock adecuadamente ($5 \to 3$ para el agua; $2 \to 1$ para el pan).test_insufficient_stock_rolls_back: Intenta comprar 3 unidades de pan teniendo solo 2 en stock. Comprueba la respuesta HTTP400y valida que la base de datos no contenga ventas (Sale.objects.count() == 0) y que el stock permanezca intacto ($2$).test_stock_failure_rolls_back_the_sale: Utilizaunittest.mock.patchpara interceptar la llamada aProduct.saveal momento de actualizar el stock y simula una excepción inesperadaRuntimeError. Se verifica que la transacción efectúe unrollbackatómico completo, dejando la tablasalesyproduct_salesen 0 filas.test_inactive_client_is_rejected: Configura al cliente comprador constatus = RecordStatus.INACTIVE. Comprueba que la API rechaza la venta con HTTP400sin crear registros de venta.
H. Análisis de Arquitectura y Decisiones de Diseño
1. APIView vs ModelViewSet
La creación de una venta comercial no es una simple operación CRUD sobre un único modelo. Involucra la alteración coordinada de múltiples tablas (sales, product_sales, products), la validación de estados del dominio en el cliente y en la jerarquía de catálogo, y un flujo estricto de descuido de inventario. Usar un ModelViewSet genérico fragmentaría la atomicidad al separar la creación de la cabecera del registro de sus detalles. Por esta razón, se adopta APIView, permitiendo estructurar un endpoint explícito dedicado únicamente a la operación de venta.
2. Ubicación de la Invariante en SaleQuerySet.register
La regla de negocio que define cómo se registra una venta no debe residir en el serializador (cuya función se limita al formateo/validación de datos HTTP) ni en clases de servicio externas desconectadas (SaleService). Al ubicar la regla de negocio directamente en el SaleQuerySet, la invariante transaccional queda protegida dentro de la capa de persistencia/dominio. Cualquier componente del sistema (vistas de la API, comandos CLI o tareas en segundo plano) utilizará Sale.objects.register(...), garantizando la ejecución de la misma transacción atómica.
3. Importancia de .order_by("id") contra Interbloqueos (Deadlocks)
Bajo escenarios de alta concurrencia, si la Transacción A solicita bloquear los Productos 1 y 2 en ese orden (SELECT FOR UPDATE), y simultáneamente la Transacción B solicita bloquear los Productos 2 y 1, el motor de base de datos puede entrar en un interbloqueo (deadlock) suspendiendo permanentemente ambas transacciones o abortando una de ellas violentamente. Al forzar .order_by("id") en la consulta select_for_update(), se garantiza que todas las transacciones activas de la aplicación adquieran los bloqueos pesimistas siguiendo un orden único de ID de producto, eliminado de raíz la posibilidad de deadlocks.
Transacción A: Bloquea Producto 1 ──► Espera Producto 2 (DEADLOCK si B no usa ordenamiento)
Transacción B: Bloquea Producto 2 ──► Espera Producto 1
Con order_by("id"):
Transacción A: Solicita [1, 2] ──► Bloquea 1 ──► Bloquea 2
Transacción B: Solicita [1, 2] ──► Encola en 1 ──► Espera liberación de A (EJECUCIÓN SEGURA)
I. Errores Frecuentes de Desarrollo y Anti-patrones
- Usar
ModelViewSetrompiendo la atomicidad: Dividir el proceso en peticiones HTTP separadas (una para crear la venta y otra para insertar las líneas). Si la segunda petición falla, la venta queda guardada sin productos. - Olvidar
select_for_update(): Leer el stock con unSELECTsimple y luego actualizarlo. Bajo peticiones concurrentes, dos procesos pueden leer la misma cantidad y provocar una condición de carrera (race condition), generando stock negativo o inconsistente. - Omisión de
.order_by("id")en consultas con bloqueo: Adquirir bloqueos pesimistas sobre productos en el orden arbitrario enviado por el JSON del usuario. Esto provocaDeadlocksindeterminados en la base de datos bajo carga de peticiones concurrentes. - Tomar precios dinámicos del catálogo al consultar la venta: No congelar
unit_priceenProductSaley leerline.product.priceen lecturas posteriores. Si el producto sube de precio en el catálogo, las ventas históricas de los clientes alterarían erróneamente sus montos facturados. - No retransmitir errores de validación de Django a DRF: No capturar
DjangoValidationErrordentro del métodocreate()del serializador para relanzarlo comoserializers.ValidationError. Esto provoca que los errores de dominio respondan con un error internoHTTP 500 Internal Server Erroren lugar de una respuesta descriptivaHTTP 400 Bad Request.
J. Criterios de Aceptación (AC-11-01 a AC-11-05)
- AC-11-01: La transacción $2 \times 10.00 + 1 \times 4.50 - 1.50$ calcula correctamente subtotal $24.50$, impuesto $0.00$, total $23.00$ y establece la venta en estado
active. - AC-11-02: Después de modificar el precio del producto en el catálogo a $99.00$, la línea de la venta histórica se mantiene congelada en $10.00$.
- AC-11-03: Una solicitud de compra con cantidad superior al stock disponible responde con código HTTP
400 Bad Requesty no crea ningún registro en la base de datos. - AC-11-04: Un fallo forzado durante el guardado del inventario invoca un
rollbacktotal: no quedan filas ensalesniproduct_sales, y el stock permanece intacto. - AC-11-05: La solicitud de compra realizada por un cliente en estado
inactivees rechazada inmediatamente con código HTTP400 Bad Request.
K. Verificación, Evidencias y Tabla GATE
Comandos de Verificación Executados
Evidencias de Ejecución
- EVI-11-01: Ejecución exitosa de los 5 casos de prueba de la clase
SaleTransactionTestscon resultadoOKen los motores de base de datos ejecutados.
Tabla GATE — ISS-11
| Criterio de Aceptación | Verificación Técnica | Evidencia Registrada | Resultado |
|---|---|---|---|
| AC-11-01 | Cálculo de totales ($23.00$), subtotal ($24.50$) e impuestos ($0.00$) | SaleTransactionTests.test_register_snapshots_price_and_discounts_stock |
PASS |
| AC-11-02 | Verificación de congelamiento de precio histórico post-modificación del catálogo | SaleTransactionTests.test_register_snapshots_price_and_discounts_stock |
PASS |
| AC-11-03 | Rechazo por stock insuficiente con estado HTTP 400 y reversión | SaleTransactionTests.test_insufficient_stock_rolls_back |
PASS |
| AC-11-04 | Simulación de error en guardado de stock mediante unittest.mock.patch demostrando rollback atómico |
SaleTransactionTests.test_stock_failure_rolls_back_the_sale |
PASS |
| AC-11-05 | Rechazo de venta a cliente inactivo con respuesta HTTP 400 | SaleTransactionTests.test_inactive_client_is_rejected |
PASS |
L. Cuestionario de Defensa Oral Técnica
Pregunta 1: ¿Por qué la venta comercial se registra mediante una APIView y un método de manager transaccional en lugar de utilizar un ModelViewSet tradicional?
Respuesta detallada:
Un ModelViewSet está diseñado para operaciones CRUD estándar donde existe un mapeo directo 1:1 con una sola tabla. El registro de una venta es un proceso de negocio complejo que afecta múltiples entidades simultáneamente (Sale, ProductSale, Product). Si se usara un ModelViewSet, el cliente tendría que crear la venta en una petición y agregar las líneas en peticiones separadas, abriendo una ventana donde las fallas de red dejarían ventas huérfanas o sin inventario descontado. APIView nos permite canalizar toda la operación en una única petición HTTP POST, invocando SaleQuerySet.register(), el cual ejecuta la creación de la venta, el congelamiento del precio, la generación de las líneas y el descuento de inventario dentro de un bloque de transacción atómico transaction.atomic().
Pregunta 2: ¿Qué función cumple el método .select_for_update() y por qué se aplica obligatoriamente sobre el cliente y los productos dentro de la transacción?
Respuesta detallada:
.select_for_update() emite la cláusula SQL SELECT ... FOR UPDATE, la cual aplica un bloqueo pesimista a nivel de fila sobre los registros seleccionados. En el caso del cliente, evita que de forma paralela otro proceso cambie su estado a inactivo mientras la venta está en proceso. En los productos, bloquea las filas de inventario impidiendo que peticiones concurrentes lean el mismo valor de stock al mismo tiempo. Sin este bloqueo, ocurriría una condición de carrera (race condition): dos compras simultáneas de la última unidad de un producto leerían stock 1, ambas aprobarían la validación de negocio y ambas descontarían la unidad, dejando el inventario en un valor negativo o inconsistente ($1 - 1 - 1 = -1$).
Pregunta 3: ¿Por qué es arquitectónicamente crítico ordenar las consultas con bloqueo mediante .order_by("id") al recuperar los productos de la venta?
Respuesta detallada:
Cuando se procesan compras concurrentes con múltiples ítems, la falta de ordenamiento al solicitar bloqueos pesimistas ocasiona interbloqueos en la base de datos (deadlocks). Si la Transacción 1 intenta bloquear el Producto A y luego el Producto B, mientras que la Transacción 2 intenta bloquear el Producto B y luego el Producto A, ambas transacciones se bloquearán mutuamente esperando que la otra libere el recurso. Al agregar .order_by("id"), garantizamos que la transacción siempre solicite la adquisición de bloqueos pesimistas en un orden estricto (por ejemplo, Producto 1 primero, Producto 2 después). De esta forma, cualquier otra transacción concurrente esperará en cola en el primer producto de coincidencia, eliminando técnicamente la posibilidad de un interbloqueo.
Pregunta 4: ¿Por qué la propiedad unit_price se almacena como una columna explícita en ProductSale en lugar de consultarse desde la relación product.price?
Respuesta detallada:
En un sistema comercial de ventas, los precios del catálogo de productos son dinámicos y varían a lo largo del tiempo debido a inflación, ajustes de mercado o promociones. Si la vista de detalle de la venta calculara el importe leyendo en vivo product.price, cualquier modificación futura al precio del catálogo alteraría retroactivamente el total facturado en ventas realizadas en el pasado. Guardar unit_price directamente en la tabla product_sales congela una foto estática del precio negociado e histórico al segundo exacto en que se confirmó la venta.
Pregunta 5: En el método create() del SaleRegisterSerializer, se capturan excepciones de tipo DjangoValidationError para relanzarlas como serializers.ValidationError. ¿Qué ocurriría en la API si se omite esta captura?
Respuesta detallada:
Si el manager lanza un django.core.exceptions.ValidationError (por ejemplo, "Stock insuficiente para Pan") y la capa del serializador no captura esta excepción, el manejador de excepciones genérico de Django REST Framework no sabrá procesarla como un error de entrada de usuario. En consecuencia, la petición HTTP fallará con un error no controlado HTTP 500 Internal Server Error, exponiendo un trazo del sistema en lugar de una respuesta limpia de cliente. Capturar DjangoValidationError y relanzarla como rest_framework.serializers.ValidationError(exc.message_dict) le permite a DRF traducir el error de dominio en un cuerpo JSON estructurado con estado HTTP 400 Bad Request.
Pregunta 6: ¿Cómo demuestra la prueba unitaria test_stock_failure_rolls_back_the_sale la propiedad de atomicidad (A de ACID)?
Respuesta detallada:
La prueba utiliza unittest.mock.patch para forzar un error inesperado (RuntimeError) al momento de guardar el nuevo stock en el modelo Product. En este punto de la ejecución, la venta borrador ya ha sido creada en la base de datos y las líneas de detalle (ProductSale) ya se han insertado en la tabla física. Al elevarse la excepción dentro del contexto with transaction.atomic():, Django aborta la ejecución y envía la orden ROLLBACK a la base de datos. La prueba comprueba posteriormente que Sale.objects.count() == 0 y ProductSale.objects.count() == 0, demostrando que no quedó ningún registro persistido a medias: la operación se realiza de forma completa o se revierte íntegramente.
Navegación de la ruta: ← ISS-10 · 🛠 Construir · ↑ Ruta Django · → ISS-11 · 🛠 Construir