feat: Add stock management features including stock reservations and updates to cart and checkout processes

This commit is contained in:
2026-04-09 10:03:57 +02:00
parent 618c65accb
commit cbcdb823db
25 changed files with 279 additions and 25 deletions
+161 -18
View File
@@ -335,7 +335,7 @@ def _cancel_active_stock_reservations_for_request(request: HttpRequest):
).update(status=StockReservation.STATUS_CANCELLED)
def _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=None):
def _get_reserved_quantities_by_product(product_ids, exclude_reservation_ids=None):
if not product_ids:
return {}
@@ -344,27 +344,38 @@ def _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=None
reservation__status=StockReservation.STATUS_ACTIVE,
reservation__expires_at__gt=timezone.now(),
)
if exclude_reservation_id:
reserved_qs = reserved_qs.exclude(reservation_id=exclude_reservation_id)
if exclude_reservation_ids:
reserved_qs = reserved_qs.exclude(reservation_id__in=exclude_reservation_ids)
reserved_totals = reserved_qs.values("product_id").annotate(total_reserved=models.Sum("quantity"))
return {row["product_id"]: row["total_reserved"] for row in reserved_totals}
def _get_available_stock_by_product(product_ids, exclude_reservation_id=None):
def _get_active_reservation_ids_for_request(request: HttpRequest):
_release_expired_stock_reservations()
return list(
StockReservation.objects.filter(
**_get_reservation_owner_filters(request),
status=StockReservation.STATUS_ACTIVE,
expires_at__gt=timezone.now(),
).values_list("id", flat=True)
)
def _get_available_stock_by_product(product_ids, exclude_reservation_ids=None):
_release_expired_stock_reservations()
products = Product.objects.filter(id__in=product_ids)
stocks = {product.id: product.stock for product in products}
reserved = _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=exclude_reservation_id)
reserved = _get_reserved_quantities_by_product(product_ids, exclude_reservation_ids=exclude_reservation_ids)
return {
product_id: max(stocks.get(product_id, 0) - reserved.get(product_id, 0), 0)
for product_id in product_ids
}
def _get_cart_stock_issues(cart_items, exclude_reservation_id=None):
def _get_cart_stock_issues(cart_items, exclude_reservation_ids=None):
product_ids = [item.product_id for item in cart_items]
available_by_product = _get_available_stock_by_product(product_ids, exclude_reservation_id=exclude_reservation_id)
available_by_product = _get_available_stock_by_product(product_ids, exclude_reservation_ids=exclude_reservation_ids)
issues = []
for item in cart_items:
@@ -527,7 +538,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
reserved_from_others = _get_reserved_quantities_by_product(
product_ids,
exclude_reservation_id=locked_reservation.id if locked_reservation else None,
exclude_reservation_ids=[locked_reservation.id] if locked_reservation else None,
)
for item in cart_items:
@@ -604,9 +615,25 @@ def add_to_cart(request: HttpRequest, product_id: int):
try:
product = Product.objects.get(id=product_id)
cart = get_or_create_cart(request)
_cancel_active_stock_reservations_for_request(request)
_clear_stock_reservation_session(request)
# Obtener cantidad del POST o por defecto 1
quantity = int(request.POST.get('quantity', 1))
if quantity <= 0:
messages.error(request, "La cantidad debe ser mayor que cero.")
return redirect('producto', id=product_id)
existing_item = CartItem.objects.filter(cart=cart, product=product).first()
desired_quantity = quantity if existing_item is None else existing_item.quantity + quantity
available = _get_available_stock_by_product([product.id]).get(product.id, 0)
if desired_quantity > available:
messages.error(
request,
f"No hay stock suficiente de '{product.name}'. Disponible: {available}, solicitado: {desired_quantity}.",
)
return redirect('producto', id=product_id)
# Buscar si ya existe el producto en el carrito
cart_item, created = CartItem.objects.get_or_create(
@@ -632,6 +659,10 @@ def add_to_cart(request: HttpRequest, product_id: int):
})
return redirect('view_cart')
except ValueError:
messages.error(request, "Cantidad no válida.")
return redirect('producto', id=product_id)
except Product.DoesNotExist:
messages.error(request, "Producto no encontrado.")
@@ -641,9 +672,13 @@ def add_to_cart(request: HttpRequest, product_id: int):
def view_cart(request: HttpRequest):
"""Muestra el contenido del carrito"""
cart = get_or_create_cart(request)
cart_items = list(cart.items.select_related("product"))
active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
return render(request, "tienda/cart.html", {
"cart": cart,
"cart_items": cart.items.all()
"cart_items": cart_items,
"stock_issues": stock_issues,
})
@@ -652,10 +687,21 @@ def update_cart_item(request: HttpRequest, item_id: int):
try:
cart = get_or_create_cart(request)
cart_item = CartItem.objects.get(id=item_id, cart=cart)
_cancel_active_stock_reservations_for_request(request)
_clear_stock_reservation_session(request)
quantity = int(request.POST.get('quantity', 1))
if quantity > 0:
available = _get_available_stock_by_product([cart_item.product_id]).get(cart_item.product_id, 0)
if quantity > available:
messages.error(
request,
f"No hay stock suficiente de '{cart_item.product.name}'. Disponible: {available}, solicitado: {quantity}.",
)
return redirect('view_cart')
cart_item.quantity = quantity
cart_item.save()
messages.success(request, "Cantidad actualizada.")
@@ -668,12 +714,17 @@ def update_cart_item(request: HttpRequest, item_id: int):
except CartItem.DoesNotExist:
messages.error(request, "Producto no encontrado en el carrito.")
return redirect('view_cart')
except ValueError:
messages.error(request, "Cantidad no válida.")
return redirect('view_cart')
def remove_from_cart(request: HttpRequest, item_id: int):
"""Elimina un producto del carrito"""
try:
cart = get_or_create_cart(request)
_cancel_active_stock_reservations_for_request(request)
_clear_stock_reservation_session(request)
cart_item = CartItem.objects.get(id=item_id, cart=cart)
product_name = cart_item.product.name
cart_item.delete()
@@ -688,6 +739,8 @@ def remove_from_cart(request: HttpRequest, item_id: int):
def clear_cart(request: HttpRequest):
"""Vacía todo el carrito"""
cart = get_or_create_cart(request)
_cancel_active_stock_reservations_for_request(request)
_clear_stock_reservation_session(request)
cart.items.all().delete()
messages.success(request, "Carrito vaciado.")
return redirect('view_cart')
@@ -769,12 +822,13 @@ def crear_producto(request: HttpRequest):
briefdesc = request.POST.get("briefdesc")
description = request.POST.get("description")
price = request.POST.get("price")
stock = request.POST.get("stock")
category_id = request.POST.get("category")
primary_image_file = request.FILES.get("primary_image")
secondary_images_files = request.FILES.getlist("secondary_images")
# Validaciones
if not all([name, description, price, category_id]):
if not all([name, description, price, stock, category_id]):
messages.error(request, "Por favor completa todos los campos obligatorios.")
categories = Category.objects.all()
return render(request, "tienda/crear_producto.html", {"categories": categories})
@@ -787,6 +841,15 @@ def crear_producto(request: HttpRequest):
messages.error(request, "El precio debe ser un número válido.")
categories = Category.objects.all()
return render(request, "tienda/crear_producto.html", {"categories": categories})
try:
stock = int(stock)
if stock < 0:
raise ValueError("El stock no puede ser negativo")
except ValueError:
messages.error(request, "El stock debe ser un número entero válido.")
categories = Category.objects.all()
return render(request, "tienda/crear_producto.html", {"categories": categories})
try:
category = Category.objects.get(id=category_id)
@@ -809,6 +872,7 @@ def crear_producto(request: HttpRequest):
briefdesc=briefdesc or "",
description=description,
price=price,
stock=stock,
category=category,
primary_image=primary_image,
creator=request.user
@@ -841,11 +905,12 @@ def editar_producto(request: HttpRequest, id: int):
briefdesc = request.POST.get("briefdesc")
description = request.POST.get("description")
price = request.POST.get("price")
stock = request.POST.get("stock")
category_id = request.POST.get("category")
primary_image_file = request.FILES.get("primary_image")
secondary_images_files = request.FILES.getlist("secondary_images")
if not all([name, description, price, category_id]):
if not all([name, description, price, stock, category_id]):
messages.error(request, "Por favor completa todos los campos obligatorios.")
categories = Category.objects.all()
return render(request, "tienda/editar_producto.html", {
@@ -865,6 +930,18 @@ def editar_producto(request: HttpRequest, id: int):
"producto": producto
})
try:
stock = int(stock)
if stock < 0:
raise ValueError("El stock no puede ser negativo")
except ValueError:
messages.error(request, "El stock debe ser un número entero válido.")
categories = Category.objects.all()
return render(request, "tienda/editar_producto.html", {
"categories": categories,
"producto": producto
})
try:
category = Category.objects.get(id=category_id)
except Category.DoesNotExist:
@@ -879,6 +956,7 @@ def editar_producto(request: HttpRequest, id: int):
producto.briefdesc = briefdesc or ""
producto.description = description
producto.price = price
producto.stock = stock
producto.category = category
if primary_image_file:
@@ -925,12 +1003,16 @@ def borrar_producto(request: HttpRequest, id: int):
@login_required
def checkout(request: HttpRequest):
cart = get_or_create_cart(request)
cart_items = cart.items.select_related("product")
cart_items = list(cart.items.select_related("product"))
active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
addresses = ShippingAddress.objects.filter(user=request.user)
return render(request, "tienda/checkout.html", {
"cart": cart,
"cart_items": cart_items,
"addresses": addresses,
"stock_issues": stock_issues,
"reservation_minutes": STOCK_RESERVATION_MINUTES,
})
@csrf_exempt
@@ -954,11 +1036,24 @@ def create_checkout_session(request: HttpRequest):
return JsonResponse({"error": "Debes seleccionar una dirección de envío válida."}, status=400)
cart = get_or_create_cart(request)
cart_items = cart.items.select_related("product")
cart_items = list(cart.items.select_related("product"))
if not cart_items.exists():
if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
if stock_issues:
return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400)
reservation, reservation_issues = _create_stock_reservation_for_cart(
request,
cart_items,
StockReservation.PAYMENT_STRIPE,
)
if reservation is None:
return JsonResponse({"error": reservation_issues[0]}, status=400)
stripe.api_key = settings.STRIPE_SECRET_KEY
line_items = []
@@ -995,6 +1090,8 @@ def create_checkout_session(request: HttpRequest):
request.session['stripe_session_id'] = session.id
request.session['selected_shipping_address_id'] = shipping_address.id
request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id
request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_STRIPE
return JsonResponse({"sessionId": session.id})
except Exception as e:
@@ -1002,20 +1099,37 @@ def create_checkout_session(request: HttpRequest):
return JsonResponse({"error": f"Error al crear sesión de pago: {str(e)}"}, status=500)
@login_required
def checkout_success(request: HttpRequest):
payment_reference = request.session.get('stripe_session_id', "")
shipping_address_id = request.session.get('selected_shipping_address_id')
shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
order = create_order_from_cart(request, Order.PAYMENT_STRIPE, payment_reference, shipping_address)
reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_STRIPE)
order, order_error = create_order_from_cart(
request,
Order.PAYMENT_STRIPE,
payment_reference,
shipping_address,
stock_reservation=reservation,
)
if order is None:
messages.error(request, order_error)
return redirect("checkout")
if 'stripe_session_id' in request.session:
del request.session['stripe_session_id']
if 'selected_shipping_address_id' in request.session:
del request.session['selected_shipping_address_id']
_clear_stock_reservation_session(request)
messages.success(request, "Pago realizado correctamente. ¡Gracias por tu compra!")
return render(request, "tienda/checkout_success.html", {"order": order})
@login_required
def checkout_cancel(request: HttpRequest):
_cancel_active_stock_reservations_for_request(request)
_clear_stock_reservation_session(request)
messages.info(request, "Pago cancelado. Puedes intentarlo de nuevo cuando quieras.")
return render(request, "tienda/checkout_cancel.html", {})
@@ -1057,11 +1171,24 @@ def create_paypal_payment(request: HttpRequest):
import paypalrestsdk
cart = get_or_create_cart(request)
cart_items = cart.items.select_related("product")
cart_items = list(cart.items.select_related("product"))
if not cart_items.exists():
if not cart_items:
return JsonResponse({"error": "El carrito está vacío"}, status=400)
active_reservation_ids = _get_active_reservation_ids_for_request(request)
stock_issues = _get_cart_stock_issues(cart_items, exclude_reservation_ids=active_reservation_ids)
if stock_issues:
return JsonResponse({"error": _build_stock_issue_message(stock_issues[0])}, status=400)
reservation, reservation_issues = _create_stock_reservation_for_cart(
request,
cart_items,
StockReservation.PAYMENT_PAYPAL,
)
if reservation is None:
return JsonResponse({"error": reservation_issues[0]}, status=400)
# Configurar PayPal
paypalrestsdk.configure({
"mode": settings.PAYPAL_MODE,
@@ -1124,6 +1251,8 @@ def create_paypal_payment(request: HttpRequest):
# Guardar el payment ID en sesión
request.session['paypal_payment_id'] = payment.id
request.session['selected_shipping_address_id'] = shipping_address.id
request.session[STOCK_RESERVATION_SESSION_KEY] = reservation.id
request.session[STOCK_RESERVATION_PAYMENT_SESSION_KEY] = StockReservation.PAYMENT_PAYPAL
# Encontrar el link de aprobación
for link in payment.links:
@@ -1178,13 +1307,25 @@ def paypal_execute(request: HttpRequest):
# Pago exitoso - crear pedido y limpiar el carrito
shipping_address_id = request.session.get('selected_shipping_address_id')
shipping_address = ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
order = create_order_from_cart(request, Order.PAYMENT_PAYPAL, payment_id, shipping_address)
reservation = _get_session_stock_reservation(request, StockReservation.PAYMENT_PAYPAL)
order, order_error = create_order_from_cart(
request,
Order.PAYMENT_PAYPAL,
payment_id,
shipping_address,
stock_reservation=reservation,
)
if order is None:
messages.error(request, order_error)
return redirect("checkout")
# Limpiar la sesión
if 'paypal_payment_id' in request.session:
del request.session['paypal_payment_id']
if 'selected_shipping_address_id' in request.session:
del request.session['selected_shipping_address_id']
_clear_stock_reservation_session(request)
messages.success(request, "¡Pago realizado correctamente con PayPal! Gracias por tu compra.")
return render(request, "tienda/checkout_success.html", {"order": order})
@@ -1197,6 +1338,8 @@ def paypal_execute(request: HttpRequest):
logger.exception("PAYPAL_EXECUTE_EXCEPTION user_id=%s error=%s", request.user.id, str(e))
messages.error(request, f"Error: {str(e)}")
return redirect("checkout")
def search_suggestions(request: HttpRequest):
"""API AJAX que retorna sugerencias de búsqueda en JSON"""
query = request.GET.get('q', '').strip()