Saltar a contenido

🛠 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
python manage.py test apps.sale

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 Sale ni ProductSale, y el stock no cambia.
  • AC-11-05: un cliente inactive es 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:

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