🛠 Unidad ISS-11 · Registro de venta con APIView — capa 🛠 CONSTRUIR
🧠 Comprender este bloque → · ✅ GATE de la unidad
Capa Página Para qué 🧠 Aprender Guía de estudio comprender, explicar y relacionar 🛠 Construir esta página ejecutar, programar y verificar ✅ GATE Condiciones de cierre condición para pasar al bloque siguiente Esta es la guía ejecutable. El cuerpo de abajo es el ISS técnico verbatim: comandos, rutas, versiones, verificaciones y criterios, sin simplificar.
ISS-11 — Registro de venta con APIView
Objetivo
Registrar una venta como una sola transacción: cliente, productos, precio histórico, totales y stock.
Requisitos
- ISS-10 superado.
- Si cualquier paso falla, no queda venta ni stock descontado.
Construcción
apps/sale/managers.py ya existe, con SaleQuerySet vacío. Se parchea: no se borra el manager.
ARCHIVO: apps/sale/managers.py
UBICAR:
from django.db import models
REEMPLAZAR POR:
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
UBICAR:
class SaleQuerySet(models.QuerySet):
pass
REEMPLAZAR POR:
class SaleQuerySet(models.QuerySet):
def register(self, *, client, sale_date, items, discounts):
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
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."]})
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."]})
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}."]}
)
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()
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
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",
]
)
for product_id, quantity in quantities.items():
product = locked_products[product_id]
product.stock -= quantity
product.save(update_fields=["stock", "updated_at"])
return sale
Si algo falla dentro de transaction.atomic(), no queda fila. SaleManager no se toca.
apps/sale/serializers.py ya tiene los serializers de lectura. Se agregan el ítem y el alta al final del archivo.
ARCHIVO: apps/sale/serializers.py
ENCIMA DE:
from rest_framework import serializers
AGREGAR:
from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
DEBAJO DE los imports existentes, AGREGAR:
from apps.client.models import Client
from apps.product.models import Product
AL FINAL DEL ARCHIVO, AGREGAR:
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
apps/sale/views.py no existe. Nace con el POST. El GET del detalle se agrega en el ISS-12. El permiso sigue siendo AllowAny hasta el ISS-22.
cat > apps/sale/views.py <<'EOF'
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)
EOF
La operación vive en SaleQuerySet.register, invocada por SaleRegisterSerializer.create. SaleRegisterAPIView.post solo valida y responde 201. SaleCollectionAPIView.post le entrega el request de DRF ya leído; no vuelve a llamar as_view().
La venta no usa router. Un SimpleRouter sobre el viewset publicaría list, create, update y destroy. Aquí el alta es una APIView y el detalle, en el ISS-12, solo admite GET y DELETE. La URL se escribe a mano.
cat > apps/sale/urls.py <<'EOF'
from django.urls import path
from apps.sale.views import SaleCollectionAPIView
urlpatterns = [
path("sales/", SaleCollectionAPIView.as_view(), name="sale-collection"),
]
EOF
ARCHIVO: config/urls.py
DEBAJO DE:
path("api/", include("apps.product.urls")),
AGREGAR:
path("api/", include("apps.sale.urls")),
path("sales/", ...) más el prefijo api/ produce POST /api/sales/. El mismo path aceptará GET en el ISS-12; no hace falta otra entrada en config/urls.py. El detalle sales/<int:pk>/ tampoco se agrega ahora.
Dentro de transaction.atomic():
bloquear cliente y productos (select_for_update, ordenados por id)
validar status y stock
crear Sale en inactive
crear ProductSale × N con unit_price = Product.price
calcular subtotal, tax = 0.00, total
pasar la venta a active
descontar stock
COMMIT
Cualquier ValidationError dentro del bloque revierte la transacción.
apps/sale/tests.py existe desde startapp. Se reescribe ahora, en acceso OPEN, y solo ejercita POST /api/sales/. El GET del detalle, el listado y el DELETE llegan en el ISS-12, cuando esas rutas existen. No hay usuario ni grant.
cat > apps/sale/tests.py <<'EOF'
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)
EOF
Explicación
Un ModelViewSet.create guardaría la cabecera y dejaría las líneas a otra petición. Eso no es atómico. La APIView existe porque el comportamiento es particular: varias filas, una regla de impuesto y un descuento de inventario.
El serializer sigue siendo el lugar de la validación de forma. El manager es el lugar de la invariante, para que también se cumpla si otra vista llama Sale.objects.register. No se creó una clase SaleService.
POST /api/sales/
│
▼
SaleCollectionAPIView
│
▼
SaleRegisterAPIView
│
▼
Sale.objects.register
│
▼
atomic → COMMIT o ROLLBACK
Cómo probarlo
Capa HTTP, acceso OPEN. Solo entra POST /api/sales/. El cliente y los productos del setUp se crean por el ORM, activos, para que la transacción tenga a quién vender. Lista, detalle y anulación se agregan en el ISS-12.
python manage.py test apps.sale
│
▼
POST /api/sales/ sin Bearer
│
├── 2×10.00 + 1×4.50 − 1.50 201 tax 0.00, total 23.00, línea en 10.00
├── cantidad mayor que el stock 400 Sale = 0, stock intacto
├── fallo al guardar el stock rollback Sale = 0 y ProductSale = 0
└── cliente inactive 400 no hay fila en sales
El POST en Swagger, sin token, es el ISS-14. Lista y anulación todavía no se pulsan: sus rutas llegan en el ISS-12 y su clic abierto, en ese mismo ISS-14.
Criterios de aceptación
- AC-11-01: 2 × 10.00 + 1 × 4.50 − 1.50 = total 23.00, tax 0.00, status
active. - AC-11-02: después de cambiar el precio del catálogo a 99, la línea sigue en 10.00.
- AC-11-03: stock insuficiente responde 400 y no crea la venta.
- AC-11-04: si falla el guardado del stock, no quedan
SaleniProductSale, y el stock no cambia. - AC-11-05: un cliente
inactivees rechazado.
Verificación
SaleTransactionTests en los tres motores.
Evidencias
- EVI-11-01: los cinco tests de la clase PASS.
GATE
| AC | Verificación | Evidencia | Resultado |
|---|---|---|---|
| AC-11-01 | totales | EVI-11-01 | PASS |
| AC-11-02 | precio histórico | EVI-11-01 | PASS |
| AC-11-03 | stock insuficiente | EVI-11-01 | PASS |
| AC-11-04 | rollback por fallo de stock | EVI-11-01 | PASS |
| AC-11-05 | cliente inactivo | EVI-11-01 | PASS |
✅ GATE de la unidad ISS-11 — este bloque no añade ningún criterio nuevo.
Las condiciones de cierre son las de esta misma página:
- 📋 Criterios de aceptación → Criterios de aceptación
- ✅ GATE → GATE
- 🔎 Verificación → Verificación
- 🧠 Autoevaluación → Evaluación del cuaderno
Con el GATE en verde queda habilitado el bloque siguiente de la ruta.
Navegación de la ruta: ← ISS-11 · 🧠 Aprender · ↑ Ruta Django · → ISS-12 · 🧠 Aprender