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, CartItem, 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, store_uuids=None): """Splits the user's multi-store cart into one Order per store. By default checks out the whole cart. If `store_uuids` is given, only those stores' groups are turned into orders and cleared from the cart — the rest of the cart (from other stores) is left untouched for a later checkout. """ cart = Cart.objects.filter(user=user).first() if not cart or not cart.items.exists(): raise ValidationError({'cart': 'سبد خرید شما خالی است.'}) groups = build_cart_groups(cart) if store_uuids is not None: wanted = {str(uuid) for uuid in store_uuids} found = {str(group['store'].uuid) for group in groups} missing = wanted - found if missing: raise ValidationError({'store_uuids': f'فروشگاه(های) {", ".join(missing)} در سبد خرید شما نیستند.'}) groups = [group for group in groups if str(group['store'].uuid) in wanted] 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']) checked_out_item_ids = [item.pk for group in groups for item in group['items']] CartItem.objects.filter(pk__in=checked_out_item_ids).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)