checkout() decrements stock_quantity/increments sold_count per order item, but cancellation never reversed it, so a cancelled order's stock stayed permanently reduced. Add _restock_order_items(), called from _transition() on the CANCELLED transition, using F() expressions to reverse both counters atomically; wrap _transition() itself in @transaction.atomic so the status change and restock can't partially apply. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
193 lines
7.5 KiB
Python
193 lines
7.5 KiB
Python
from itertools import groupby
|
|
|
|
from django.db import transaction
|
|
from django.db.models import F
|
|
from django.utils import timezone
|
|
from rest_framework.exceptions import ValidationError
|
|
|
|
from utils.exceptions import Conflict
|
|
|
|
from apps.catalog.models import Product
|
|
|
|
from .models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
|
|
|
|
|
|
def build_cart_groups(cart):
|
|
"""Groups a Cart's items by store, computing each store's subtotal/delivery fee/total.
|
|
|
|
Shared by the cart-retrieve serializer and checkout, since checkout must split a
|
|
multi-store cart into one Order per store using the same numbers the customer saw
|
|
in their cart.
|
|
"""
|
|
items = list(
|
|
cart.items.select_related('product', 'product__store').order_by('product__store_id')
|
|
)
|
|
groups = []
|
|
for store, store_items in groupby(items, key=lambda item: item.product.store):
|
|
store_items = list(store_items)
|
|
items_subtotal = sum(item.line_total for item in store_items)
|
|
meets_minimum = items_subtotal >= store.min_order_amount
|
|
delivery_fee = 0 if (
|
|
store.free_delivery_threshold and items_subtotal >= store.free_delivery_threshold
|
|
) else store.delivery_fee
|
|
groups.append({
|
|
'store': store,
|
|
'items': store_items,
|
|
'items_subtotal': items_subtotal,
|
|
'delivery_fee': delivery_fee,
|
|
'total': items_subtotal + delivery_fee,
|
|
'meets_minimum_order': meets_minimum,
|
|
})
|
|
return groups
|
|
|
|
|
|
def notify(recipient, type, title, body='', related_order=None):
|
|
return Notification.objects.create(
|
|
recipient=recipient, type=type, title=title, body=body, related_order=related_order,
|
|
)
|
|
|
|
|
|
def _build_full_address(address):
|
|
parts = [address.city.name]
|
|
if address.neighborhood:
|
|
parts.append(address.neighborhood.name)
|
|
parts.append(address.full_address)
|
|
for label, value in (('پلاک', address.plaque), ('طبقه', address.floor), ('واحد', address.unit)):
|
|
if value:
|
|
parts.append(f'{label} {value}')
|
|
return '، '.join(parts)
|
|
|
|
|
|
@transaction.atomic
|
|
def checkout(user, address, delivery_type, scheduled_at, payment_method, notes):
|
|
"""Splits the user's multi-store cart into one Order per store."""
|
|
cart = Cart.objects.filter(user=user).first()
|
|
if not cart or not cart.items.exists():
|
|
raise ValidationError({'cart': 'سبد خرید شما خالی است.'})
|
|
|
|
groups = build_cart_groups(cart)
|
|
|
|
for group in groups:
|
|
store = group['store']
|
|
if not group['meets_minimum_order']:
|
|
raise ValidationError({
|
|
'cart': f'حداقل مبلغ سفارش از فروشگاه «{store.name}» {store.min_order_amount} تومان است.',
|
|
})
|
|
if store.service_neighborhoods.exists() and address.neighborhood_id and not store.service_neighborhoods.filter(
|
|
uuid=address.neighborhood_id,
|
|
).exists():
|
|
raise ValidationError({
|
|
'address_uuid': f'فروشگاه «{store.name}» به محله انتخابی شما ارسال ندارد.',
|
|
})
|
|
for item in group['items']:
|
|
if item.quantity > item.product.stock_quantity:
|
|
raise ValidationError({'cart': f'موجودی «{item.product.name}» کافی نیست.'})
|
|
|
|
total_amount = sum(group['total'] for group in groups)
|
|
|
|
order_group = OrderGroup.objects.create(
|
|
user=user,
|
|
recipient_name=address.recipient_name,
|
|
recipient_phone=address.recipient_phone,
|
|
full_address=_build_full_address(address),
|
|
delivery_type=delivery_type,
|
|
scheduled_at=scheduled_at,
|
|
payment_method=payment_method,
|
|
notes=notes,
|
|
total_amount=total_amount,
|
|
)
|
|
|
|
for group in groups:
|
|
store = group['store']
|
|
commission_amount = round(group['items_subtotal'] * float(store.commission_percent_effective) / 100)
|
|
order = Order.objects.create(
|
|
group=order_group,
|
|
store=store,
|
|
customer=user,
|
|
items_subtotal=group['items_subtotal'],
|
|
delivery_fee=group['delivery_fee'],
|
|
commission_amount=commission_amount,
|
|
seller_payout_amount=group['items_subtotal'] - commission_amount,
|
|
)
|
|
OrderItem.objects.bulk_create([
|
|
OrderItem(
|
|
order=order,
|
|
product=item.product,
|
|
product_name_snapshot=item.product.name,
|
|
unit_price_snapshot=item.product.price,
|
|
quantity=item.quantity,
|
|
)
|
|
for item in group['items']
|
|
])
|
|
OrderStatusLog.objects.create(order=order, from_status='', to_status=Order.Status.PLACED)
|
|
|
|
notify(
|
|
recipient=store.owner,
|
|
type=Notification.Type.NEW_ORDER,
|
|
title='سفارش جدید دریافت شد',
|
|
body=f'سفارش شماره {order.uuid} ثبت شد. لطفاً برای آمادهسازی اقدام کنید.',
|
|
related_order=order,
|
|
)
|
|
|
|
for item in group['items']:
|
|
item.product.stock_quantity -= item.quantity
|
|
item.product.sold_count += item.quantity
|
|
item.product.save(update_fields=['stock_quantity', 'sold_count', 'updated_at'])
|
|
|
|
cart.items.all().delete()
|
|
|
|
return order_group
|
|
|
|
|
|
def _restock_order_items(order):
|
|
"""Reverses the stock_quantity/sold_count decrement made at checkout (see checkout())."""
|
|
for item in order.items.select_related('product').filter(product__isnull=False):
|
|
Product.objects.filter(pk=item.product_id).update(
|
|
stock_quantity=F('stock_quantity') + item.quantity,
|
|
sold_count=F('sold_count') - item.quantity,
|
|
)
|
|
|
|
|
|
@transaction.atomic
|
|
def _transition(order, to_status, *, changed_by=None, note='', **extra_fields):
|
|
from_status = order.status
|
|
order.status = to_status
|
|
for field, value in extra_fields.items():
|
|
setattr(order, field, value)
|
|
if to_status == Order.Status.DELIVERED:
|
|
order.delivered_at = timezone.now()
|
|
order.save()
|
|
OrderStatusLog.objects.create(order=order, from_status=from_status, to_status=to_status, changed_by=changed_by, note=note)
|
|
|
|
if to_status == Order.Status.CANCELLED:
|
|
_restock_order_items(order)
|
|
notify(
|
|
recipient=order.store.owner,
|
|
type=Notification.Type.ORDER_CANCELLED,
|
|
title='سفارش لغو شد',
|
|
body=f'سفارش شماره {order.uuid} لغو شد.' + (f' دلیل: {note}' if note else ''),
|
|
related_order=order,
|
|
)
|
|
|
|
return order
|
|
|
|
|
|
_ALLOWED_TRANSITIONS = {
|
|
Order.Status.PLACED: {Order.Status.PREPARING, Order.Status.CANCELLED},
|
|
Order.Status.PREPARING: {Order.Status.READY_TO_SHIP, Order.Status.CANCELLED},
|
|
Order.Status.READY_TO_SHIP: {Order.Status.HANDED_TO_COURIER},
|
|
Order.Status.HANDED_TO_COURIER: {Order.Status.DELIVERED},
|
|
}
|
|
|
|
|
|
def advance_status(order, to_status, *, changed_by=None, **kwargs):
|
|
allowed = _ALLOWED_TRANSITIONS.get(order.status, set())
|
|
if to_status not in allowed:
|
|
raise Conflict(f'امکان تغییر وضعیت سفارش از «{order.get_status_display()}» به این حالت وجود ندارد.')
|
|
return _transition(order, to_status, changed_by=changed_by, **kwargs)
|
|
|
|
|
|
def cancel_order(order, *, changed_by=None, reason=''):
|
|
if order.status not in Order.CANCELLABLE_STATUSES:
|
|
raise Conflict('این سفارش دیگر قابل لغو نیست.')
|
|
return _transition(order, Order.Status.CANCELLED, changed_by=changed_by, note=reason, cancel_reason=reason)
|