feat: Implement stock reservation management in cart and order creation
This commit is contained in:
+217
-4
@@ -33,6 +33,8 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger("tienda")
|
logger = logging.getLogger("tienda")
|
||||||
audit_logger = logging.getLogger("tienda.audit")
|
audit_logger = logging.getLogger("tienda.audit")
|
||||||
|
STOCK_RESERVATION_SESSION_KEY = "stock_reservation_id"
|
||||||
|
STOCK_RESERVATION_PAYMENT_SESSION_KEY = "stock_reservation_payment_method"
|
||||||
|
|
||||||
|
|
||||||
def _normalize_location_text(value: str) -> str:
|
def _normalize_location_text(value: str) -> str:
|
||||||
@@ -299,6 +301,154 @@ def get_or_create_cart(request):
|
|||||||
return cart
|
return cart
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_session_key(request: HttpRequest):
|
||||||
|
if not request.session.session_key:
|
||||||
|
request.session.create()
|
||||||
|
return request.session.session_key
|
||||||
|
|
||||||
|
|
||||||
|
def _get_reservation_owner_filters(request: HttpRequest):
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return {"user": request.user}
|
||||||
|
return {"session_key": _get_or_create_session_key(request)}
|
||||||
|
|
||||||
|
|
||||||
|
def _release_expired_stock_reservations():
|
||||||
|
now = timezone.now()
|
||||||
|
StockReservation.objects.filter(
|
||||||
|
status=StockReservation.STATUS_ACTIVE,
|
||||||
|
expires_at__lte=now,
|
||||||
|
).update(status=StockReservation.STATUS_EXPIRED)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_stock_reservation_session(request: HttpRequest):
|
||||||
|
request.session.pop(STOCK_RESERVATION_SESSION_KEY, None)
|
||||||
|
request.session.pop(STOCK_RESERVATION_PAYMENT_SESSION_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _cancel_active_stock_reservations_for_request(request: HttpRequest):
|
||||||
|
_release_expired_stock_reservations()
|
||||||
|
StockReservation.objects.filter(
|
||||||
|
**_get_reservation_owner_filters(request),
|
||||||
|
status=StockReservation.STATUS_ACTIVE,
|
||||||
|
expires_at__gt=timezone.now(),
|
||||||
|
).update(status=StockReservation.STATUS_CANCELLED)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_reserved_quantities_by_product(product_ids, exclude_reservation_id=None):
|
||||||
|
if not product_ids:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
reserved_qs = StockReservationItem.objects.filter(
|
||||||
|
product_id__in=product_ids,
|
||||||
|
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)
|
||||||
|
|
||||||
|
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):
|
||||||
|
_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)
|
||||||
|
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):
|
||||||
|
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)
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
for item in cart_items:
|
||||||
|
available = available_by_product.get(item.product_id, 0)
|
||||||
|
if item.quantity > available:
|
||||||
|
issues.append({
|
||||||
|
"product_name": item.product.name,
|
||||||
|
"requested": item.quantity,
|
||||||
|
"available": available,
|
||||||
|
})
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _build_stock_issue_message(issue):
|
||||||
|
return (
|
||||||
|
f"No hay stock suficiente de '{issue['product_name']}'. "
|
||||||
|
f"Disponible: {issue['available']}, solicitado: {issue['requested']}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_stock_reservation_for_cart(request: HttpRequest, cart_items, payment_method: str):
|
||||||
|
if not cart_items:
|
||||||
|
return None, ["El carrito está vacío."]
|
||||||
|
|
||||||
|
_release_expired_stock_reservations()
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
StockReservation.objects.select_for_update().filter(
|
||||||
|
**_get_reservation_owner_filters(request),
|
||||||
|
status=StockReservation.STATUS_ACTIVE,
|
||||||
|
expires_at__gt=timezone.now(),
|
||||||
|
).update(status=StockReservation.STATUS_CANCELLED)
|
||||||
|
|
||||||
|
product_ids = [item.product_id for item in cart_items]
|
||||||
|
products = Product.objects.select_for_update().filter(id__in=product_ids)
|
||||||
|
product_stock = {product.id: product.stock for product in products}
|
||||||
|
reserved = _get_reserved_quantities_by_product(product_ids)
|
||||||
|
|
||||||
|
issues = []
|
||||||
|
for item in cart_items:
|
||||||
|
available = max(product_stock.get(item.product_id, 0) - reserved.get(item.product_id, 0), 0)
|
||||||
|
if item.quantity > available:
|
||||||
|
issues.append(_build_stock_issue_message({
|
||||||
|
"product_name": item.product.name,
|
||||||
|
"requested": item.quantity,
|
||||||
|
"available": available,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
return None, issues
|
||||||
|
|
||||||
|
reservation = StockReservation.objects.create(
|
||||||
|
user=request.user if request.user.is_authenticated else None,
|
||||||
|
session_key=None if request.user.is_authenticated else _get_or_create_session_key(request),
|
||||||
|
payment_method=payment_method,
|
||||||
|
expires_at=timezone.now() + timedelta(minutes=STOCK_RESERVATION_MINUTES),
|
||||||
|
)
|
||||||
|
StockReservationItem.objects.bulk_create([
|
||||||
|
StockReservationItem(
|
||||||
|
reservation=reservation,
|
||||||
|
product=item.product,
|
||||||
|
quantity=item.quantity,
|
||||||
|
)
|
||||||
|
for item in cart_items
|
||||||
|
])
|
||||||
|
|
||||||
|
return reservation, []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session_stock_reservation(request: HttpRequest, payment_method: str):
|
||||||
|
reservation_id = request.session.get(STOCK_RESERVATION_SESSION_KEY)
|
||||||
|
reservation_payment_method = request.session.get(STOCK_RESERVATION_PAYMENT_SESSION_KEY)
|
||||||
|
if not reservation_id or reservation_payment_method != payment_method:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return StockReservation.objects.filter(
|
||||||
|
id=reservation_id,
|
||||||
|
status=StockReservation.STATUS_ACTIVE,
|
||||||
|
expires_at__gt=timezone.now(),
|
||||||
|
payment_method=payment_method,
|
||||||
|
**_get_reservation_owner_filters(request),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
def _get_selected_shipping_address(request: HttpRequest):
|
def _get_selected_shipping_address(request: HttpRequest):
|
||||||
"""Obtiene la dirección seleccionada desde JSON o form-data y valida pertenencia al usuario."""
|
"""Obtiene la dirección seleccionada desde JSON o form-data y valida pertenencia al usuario."""
|
||||||
shipping_address_id = request.POST.get("shipping_address_id")
|
shipping_address_id = request.POST.get("shipping_address_id")
|
||||||
@@ -321,13 +471,13 @@ def _get_selected_shipping_address(request: HttpRequest):
|
|||||||
return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
|
return ShippingAddress.objects.filter(id=shipping_address_id, user=request.user).first()
|
||||||
|
|
||||||
|
|
||||||
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None):
|
def create_order_from_cart(request, payment_method, payment_reference="", shipping_address=None, stock_reservation=None):
|
||||||
"""Crea un pedido a partir del carrito actual y lo asigna a vendedores."""
|
"""Crea un pedido a partir del carrito actual, validando y descontando stock."""
|
||||||
cart = get_or_create_cart(request)
|
cart = get_or_create_cart(request)
|
||||||
cart_items = list(cart.items.select_related("product", "product__creator"))
|
cart_items = list(cart.items.select_related("product", "product__creator"))
|
||||||
|
|
||||||
if not cart_items:
|
if not cart_items:
|
||||||
return None
|
return None, "El carrito está vacío."
|
||||||
|
|
||||||
order_total = Decimal("0.00")
|
order_total = Decimal("0.00")
|
||||||
items_with_totals = []
|
items_with_totals = []
|
||||||
@@ -350,7 +500,62 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_release_expired_stock_reservations()
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
locked_reservation = None
|
||||||
|
reserved_by_product = {}
|
||||||
|
|
||||||
|
if stock_reservation is not None:
|
||||||
|
locked_reservation = StockReservation.objects.select_for_update().filter(
|
||||||
|
id=stock_reservation.id,
|
||||||
|
status=StockReservation.STATUS_ACTIVE,
|
||||||
|
expires_at__gt=timezone.now(),
|
||||||
|
).first()
|
||||||
|
if locked_reservation is None:
|
||||||
|
return None, (
|
||||||
|
f"La reserva de stock ha caducado. Tienes {STOCK_RESERVATION_MINUTES} minutos "
|
||||||
|
"desde que pulsas pagar. Revisa el carrito y vuelve a intentarlo."
|
||||||
|
)
|
||||||
|
|
||||||
|
for reservation_item in locked_reservation.items.all():
|
||||||
|
reserved_by_product[reservation_item.product_id] = reservation_item.quantity
|
||||||
|
|
||||||
|
product_ids = [item.product_id for item in cart_items]
|
||||||
|
products = Product.objects.select_for_update().filter(id__in=product_ids)
|
||||||
|
product_map = {product.id: product for product in products}
|
||||||
|
|
||||||
|
reserved_from_others = _get_reserved_quantities_by_product(
|
||||||
|
product_ids,
|
||||||
|
exclude_reservation_id=locked_reservation.id if locked_reservation else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in cart_items:
|
||||||
|
product = product_map.get(item.product_id)
|
||||||
|
if product is None:
|
||||||
|
return None, f"El producto '{item.product.name}' ya no está disponible."
|
||||||
|
|
||||||
|
if locked_reservation is not None and item.quantity > reserved_by_product.get(item.product_id, 0):
|
||||||
|
return None, (
|
||||||
|
f"La cantidad de '{item.product.name}' ha cambiado desde la reserva. "
|
||||||
|
"Vuelve a intentar el pago."
|
||||||
|
)
|
||||||
|
|
||||||
|
available = max(product.stock - reserved_from_others.get(item.product_id, 0), 0)
|
||||||
|
if item.quantity > available:
|
||||||
|
return None, _build_stock_issue_message({
|
||||||
|
"product_name": item.product.name,
|
||||||
|
"requested": item.quantity,
|
||||||
|
"available": available,
|
||||||
|
})
|
||||||
|
|
||||||
|
if product.stock < item.quantity:
|
||||||
|
return None, _build_stock_issue_message({
|
||||||
|
"product_name": item.product.name,
|
||||||
|
"requested": item.quantity,
|
||||||
|
"available": product.stock,
|
||||||
|
})
|
||||||
|
|
||||||
order = Order.objects.create(
|
order = Order.objects.create(
|
||||||
buyer=request.user if request.user.is_authenticated else None,
|
buyer=request.user if request.user.is_authenticated else None,
|
||||||
shipping_address=shipping_address,
|
shipping_address=shipping_address,
|
||||||
@@ -373,8 +578,16 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
|
|||||||
total_price=float(line_total_with_vat),
|
total_price=float(line_total_with_vat),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
product_row = product_map.get(item.product_id)
|
||||||
|
product_row.stock -= item.quantity
|
||||||
|
product_row.save(update_fields=["stock"])
|
||||||
|
|
||||||
cart.items.all().delete()
|
cart.items.all().delete()
|
||||||
|
|
||||||
|
if locked_reservation is not None:
|
||||||
|
locked_reservation.status = StockReservation.STATUS_COMPLETED
|
||||||
|
locked_reservation.save(update_fields=["status", "updated_at"])
|
||||||
|
|
||||||
if request.user.is_authenticated and purchased_items:
|
if request.user.is_authenticated and purchased_items:
|
||||||
tasks.process_purchase.delay(
|
tasks.process_purchase.delay(
|
||||||
request.user.id,
|
request.user.id,
|
||||||
@@ -383,7 +596,7 @@ def create_order_from_cart(request, payment_method, payment_reference="", shippi
|
|||||||
order.transaction_code,
|
order.transaction_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
return order
|
return order, ""
|
||||||
|
|
||||||
|
|
||||||
def add_to_cart(request: HttpRequest, product_id: int):
|
def add_to_cart(request: HttpRequest, product_id: int):
|
||||||
|
|||||||
Reference in New Issue
Block a user