Arquitectura del Proceso de Compra
La transición de artículos en el carrito a un pedido consolidado requiere una orquestación precisa de múltiples operaciones de base de datos. El flujo del usuario abarca desde la selección de productos hasta la validación de inventario, cálculo de totales, generación del registro de compra y limpieza del carrito. Para garantizar la integridad de los datos, es imperativo encapsular estas operaciones dentro de una transacción atómica, asegurando que cualquier fallo revierta todos los cambios.
Definición de Endpoints
Se requieren rutas específicas para mostrar la página de revisión y para procesar la transacción final. En el módulo de rutas de la aplicación de pedidos:
from django.urls import path
from . import views
app_name = 'orders'
urlpatterns = [
path('checkout/', views.checkout_view, name='checkout'),
path('process/', views.process_order, name='process_order'),
path('details/<int:order_id>/', views.view_order_details, name='order_details'),
]
Estas rutas deben integrarse en el archivo princiapl de URLs del proyecto mediante include. Además, el botón de finalización de compra en la plantilla del carrito debe apuntar a {% url 'orders:checkout' %}.
Vista de Revisión de Compra
Esta vista recopila los artículos seleccionados, valida el stock disponible y prepara los datos de envío y financieros para la plantilla.
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from cart.models import CartItem
@login_required(login_url='accounts:signin')
def checkout_view(request):
selected_products = CartItem.objects.filter(
buyer=request.user,
is_selected=True
).select_related('product_variant__item').prefetch_related('product_variant__photos')
if not selected_products:
messages.warning(request, 'Debe seleccionar al menos un producto para continuar.')
return redirect('cart:view_cart')
for cart_entry in selected_products:
if cart_entry.amount > cart_entry.product_variant.inventory:
messages.error(request, f'Stock insuficiente para "{cart_entry.product_variant.title}". Solo quedan {cart_entry.product_variant.inventory} unidades.')
return redirect('cart:view_cart')
user_addresses = request.user.shipping_addresses.all()
if not user_addresses:
messages.info(request, 'Por favor, añada una dirección de envío.')
return redirect('accounts:add_address')
grand_total = sum(entry.product_variant.cost * entry.amount for entry in selected_products)
context = {
'cart_entries': selected_products,
'shipping_addresses': user_addresses,
'grand_total': grand_total,
}
return render(request, 'orders/checkout_page.html', context)
Interfaz de Usuario para Confirmación
La plantilla presenta las direcciones guardadas, el desglose de artículos y el resumen financiero. A continuación, se muestra la estructura principal:
{% extends 'base.html' %}
{% block content %}
<h3>Revisión de Compra</h3>
<form method="post" action="{% url 'orders:process_order' %}">
{% csrf_token %}
<div class="card mb-4">
<div class="card-header">Dirección de Envío</div>
<div class="card-body">
{% for addr in shipping_addresses %}
<div class="form-check mb-2">
<input class="form-check-input" type="radio" name="shipping_address" value="{{ addr.id }}" {% if addr.is_default %}checked{% endif %}>
<label class="form-check-label">
<strong>{{ addr.recipient_name }}</strong> - {{ addr.contact_number }}<br>
{{ addr.region }}, {{ addr.locality }}, {{ addr.specifics }}
</label>
</div>
{% endfor %}
</div>
</div>
<div class="card mb-4">
<div class="card-header">Artículos</div>
<table class="table">
<thead>
<tr><th>Producto</th><th>Precio</th><th>Cantidad</th><th>Subtotal</th></tr>
</thead>
<tbody>
{% for entry in cart_entries %}
<tr>
<td>{{ entry.product_variant.title }}</td>
<td>${{ entry.product_variant.cost }}</td>
<td>{{ entry.amount }}</td>
<td>${{ entry.product_variant.cost|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card mb-4">
<div class="card-body">
<label class="form-label">Notas adicionales</label>
<textarea name="buyer_notes" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="text-end">
<h4>Total a Pagar: ${{ grand_total|floatformat:2 }}</h4>
<button type="submit" class="btn btn-success btn-lg">Confirmar Pedido</button>
</div>
</form>
{% endblock %}
Procesamiento del Pedido (Lógica Core)
El núcleo del sistema reside en la función que procesa la transacción. Se utilizan bloqueos a nivel de fila (select_for_update) y expresiones F() para evitar condiciones de carrera y sobreventa en entornos concurrentes.
from django.shortcuts import get_object_or_404, redirect
from django.db import transaction, IntegrityError
from django.db.models import F
from django.views.decorators.http import require_POST
from django.contrib import messages
from catalog.models import ProductVariant
from accounts.models import ShippingAddress
from .models import PurchaseOrder, OrderLineItem
from cart.models import CartItem
@require_POST
@login_required(login_url='accounts:signin')
@transaction.atomic
def process_order(request):
buyer = request.user
addr_id = request.POST.get('shipping_address')
notes = request.POST.get('buyer_notes', '')
if not addr_id:
messages.error(request, 'Es obligatorio seleccionar una dirección de envío.')
return redirect('orders:checkout')
destination = get_object_or_404(ShippingAddress, id=addr_id, owner=buyer)
selected_products = CartItem.objects.filter(
buyer=buyer,
is_selected=True
).select_related('product_variant')
if not selected_products:
messages.warning(request, 'Su carrito no tiene productos seleccionados.')
return redirect('cart:view_cart')
variant_ids = [entry.product_variant_id for entry in selected_products]
locked_variants = ProductVariant.objects.select_for_update().in_bulk(variant_ids)
total_cost = 0
line_items_to_create = []
for entry in selected_products:
variant = locked_variants[entry.product_variant_id]
if entry.amount > variant.inventory:
raise ValueError(f'Stock insuficiente para {variant.title}')
ProductVariant.objects.filter(id=variant.id).update(
inventory=F('inventory') - entry.amount,
units_sold=F('units_sold') + entry.amount
)
line_total = variant.cost * entry.amount
total_cost += line_total
line_items_to_create.append(OrderLineItem(
variant=variant,
snapshot_title=variant.title,
snapshot_specs=variant.specifications,
unit_price=variant.cost,
quantity=entry.amount,
))
address_data = {
'recipient': destination.recipient_name,
'contact_number': destination.phone_number,
'region': destination.region,
'locality': destination.locality,
'zone': destination.zone,
'specifics': destination.specifics,
}
order_reference = PurchaseOrder.create_reference()
attempts = 0
while attempts < 3:
try:
new_order = PurchaseOrder.objects.create(
reference_code=order_reference,
buyer=buyer,
shipping_details=address_data,
final_amount=total_cost,
current_state=0,
buyer_notes=notes,
)
break
except IntegrityError:
order_reference = PurchaseOrder.create_reference()
attempts += 1
else:
messages.error(request, 'No se pudo generar el pedido. Inténtelo de nuevo.')
return redirect('orders:checkout')
for line in line_items_to_create:
line.parent_order = new_order
OrderLineItem.objects.bulk_create(line_items_to_create)
selected_products.delete()
messages.success(request, f'Pedido {order_reference} creado exitosamente.')
return redirect('orders:order_details', order_id=new_order.id)
Visualización del Resumen del Pedido
Una vez completada la transación, el usuario es redirigido a una página de resumen. Esta vista recupera el pedido y sus líneas asociadas.
@login_required(login_url='accounts:signin')
def view_order_details(request, order_id):
purchase = get_object_or_404(
PurchaseOrder.objects.prefetch_related('line_items__variant__photos'),
id=order_id,
buyer=request.user
)
return render(request, 'orders/order_summary.html', {'purchase': purchase})
La plantilla order_summary.html itera sobre purchase.line_items.all para mostrar el título snapshot, precio unitario, cantidad y el monto final, garantizando que la información histórica permanezca intacta incluso si el catálogo original se modifica posteriormente.