winofy-backend/apps/orders/services.py
Ali Asadi 52adc732fa Initial Winofy backend: marketplace core (no payment integration)
Django 6 + DRF resource server against the Gooyal accounts OAuth2 service,
matching the Winsoo ecosystem's conventions. Covers locations, stores,
catalog, cart, checkout/orders (with the multi-store-cart split and the
status stepper), reviews, and notifications, plus a demo-data seed command.

Payment integration (wallet debits, online gateway, seller payouts) is
intentionally left out here — see feature/payment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 14:03:15 +03:30

179 lines
7 KiB
Python

from itertools import groupby
from django.db import transaction
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from utils.exceptions import Conflict
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 _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:
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)