feature/selective-checkout #10

Merged
Ghasemi merged 1 commit from feature/selective-checkout into master 2026-09-05 08:16:47 -04:00
4 changed files with 56 additions and 4 deletions

View file

@ -6,6 +6,8 @@
- `GET /api/v1/stores/` — documented the `category`, `neighborhood`, `search`, `lat`, and `lng` query params for drf-spectacular so they render in swagger. The filtering itself already worked; only the OpenAPI schema was missing them (`apps/stores/views.py`). - `GET /api/v1/stores/` — documented the `category`, `neighborhood`, `search`, `lat`, and `lng` query params for drf-spectacular so they render in swagger. The filtering itself already worked; only the OpenAPI schema was missing them (`apps/stores/views.py`).
- `GET /api/v1/products/` — same issue: documented the existing `store`, `category`, and `search` query params for swagger (`apps/catalog/views.py`). - `GET /api/v1/products/` — same issue: documented the existing `store`, `category`, and `search` query params for swagger (`apps/catalog/views.py`).
- Cart items: `CartItemView` was registered on both `/api/v1/cart/items/` and `/api/v1/cart/items/{item_uuid}/`, so swagger showed POST/PATCH/DELETE on both paths — 3 of those 6 combinations crashed with a `TypeError` at runtime (`item_uuid` missing or unexpected). Split into `CartItemView` (POST only, list path) and `CartItemDetailView` (PATCH/DELETE only, detail path) so swagger only shows the combinations that actually work (`apps/orders/views.py`, `apps/orders/urls.py`). - Cart items: `CartItemView` was registered on both `/api/v1/cart/items/` and `/api/v1/cart/items/{item_uuid}/`, so swagger showed POST/PATCH/DELETE on both paths — 3 of those 6 combinations crashed with a `TypeError` at runtime (`item_uuid` missing or unexpected). Split into `CartItemView` (POST only, list path) and `CartItemDetailView` (PATCH/DELETE only, detail path) so swagger only shows the combinations that actually work (`apps/orders/views.py`, `apps/orders/urls.py`).
- Cancelling an order never restored the product stock that `checkout()` decremented, so cancelled orders' items stayed permanently out of stock. Added `_restock_order_items()`, run on the `CANCELLED` transition (`apps/orders/services.py`).
- `POST /api/v1/checkout/` always checked out the customer's entire cart, even when it held items from stores the customer didn't intend to order from yet. It now checks out only the requested stores when `store_uuids` is given (default: whole cart, unchanged), and only clears those stores' items — the rest of the cart is left intact for a later checkout (`apps/orders/serializers.py`, `apps/orders/services.py`).
### Added ### Added
- `GET /api/v1/cities/?search=` — search cities by name (`apps/locations/views.py`); previously no text search existed. - `GET /api/v1/cities/?search=` — search cities by name (`apps/locations/views.py`); previously no text search existed.

View file

@ -106,6 +106,10 @@ class CheckoutSerializer(serializers.Serializer):
scheduled_at = serializers.DateTimeField(required=False, allow_null=True, default=None) scheduled_at = serializers.DateTimeField(required=False, allow_null=True, default=None)
payment_method = serializers.ChoiceField(choices=OrderGroup.PaymentMethod.choices) payment_method = serializers.ChoiceField(choices=OrderGroup.PaymentMethod.choices)
notes = serializers.CharField(required=False, allow_blank=True, default='') notes = serializers.CharField(required=False, allow_blank=True, default='')
store_uuids = serializers.ListField(
child=serializers.UUIDField(), required=False, allow_empty=False, default=None,
help_text='بررسی و پرداخت فقط برای فروشگاه‌های انتخاب‌شده از سبد خرید. در صورت خالی بودن، کل سبد خرید نهایی می‌شود.',
)
def validate(self, attrs): def validate(self, attrs):
if attrs.get('delivery_type') == OrderGroup.DeliveryType.SCHEDULED and not attrs.get('scheduled_at'): if attrs.get('delivery_type') == OrderGroup.DeliveryType.SCHEDULED and not attrs.get('scheduled_at'):

View file

@ -9,7 +9,7 @@ from utils.exceptions import Conflict
from apps.catalog.models import Product from apps.catalog.models import Product
from .models import Cart, Notification, Order, OrderGroup, OrderItem, OrderStatusLog from .models import Cart, CartItem, Notification, Order, OrderGroup, OrderItem, OrderStatusLog
def build_cart_groups(cart): def build_cart_groups(cart):
@ -59,14 +59,27 @@ def _build_full_address(address):
@transaction.atomic @transaction.atomic
def checkout(user, address, delivery_type, scheduled_at, payment_method, notes): 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.""" """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() cart = Cart.objects.filter(user=user).first()
if not cart or not cart.items.exists(): if not cart or not cart.items.exists():
raise ValidationError({'cart': 'سبد خرید شما خالی است.'}) raise ValidationError({'cart': 'سبد خرید شما خالی است.'})
groups = build_cart_groups(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: for group in groups:
store = group['store'] store = group['store']
if not group['meets_minimum_order']: if not group['meets_minimum_order']:
@ -134,7 +147,8 @@ def checkout(user, address, delivery_type, scheduled_at, payment_method, notes):
item.product.sold_count += item.quantity item.product.sold_count += item.quantity
item.product.save(update_fields=['stock_quantity', 'sold_count', 'updated_at']) item.product.save(update_fields=['stock_quantity', 'sold_count', 'updated_at'])
cart.items.all().delete() 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 return order_group

View file

@ -33,6 +33,38 @@ class CheckoutTests(OrdersTestCase):
self.product1.refresh_from_db() self.product1.refresh_from_db()
self.assertEqual(self.product1.stock_quantity, 48) self.assertEqual(self.product1.stock_quantity, 48)
def test_checkout_can_be_limited_to_selected_stores(self):
self.add_to_cart(self.product1, quantity=1)
self.add_to_cart(self.product2, quantity=1)
response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid),
'payment_method': OrderGroup.PaymentMethod.CASH_ON_DELIVERY,
'store_uuids': [str(self.store1.uuid)],
})
self.assertEqual(response.status_code, 201, response.data)
group = OrderGroup.objects.get(uuid=response.data['uuid'])
self.assertEqual(group.orders.count(), 1)
self.assertEqual(group.orders.get().store, self.store1)
# store2's cart item is left untouched for a later checkout.
remaining_cart = Cart.objects.get(user=self.customer)
self.assertEqual(remaining_cart.items.count(), 1)
self.assertEqual(remaining_cart.items.get().product, self.product2)
def test_checkout_rejects_store_uuid_not_in_cart(self):
self.add_to_cart(self.product1, quantity=1)
response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid),
'payment_method': OrderGroup.PaymentMethod.CASH_ON_DELIVERY,
'store_uuids': [str(self.store2.uuid)],
})
self.assertEqual(response.status_code, 400)
self.assertTrue(Cart.objects.get(user=self.customer).items.exists())
def test_checkout_rejects_empty_cart(self): def test_checkout_rejects_empty_cart(self):
response = self.client.post('/api/v1/checkout/', { response = self.client.post('/api/v1/checkout/', {
'address_uuid': str(self.address.uuid), 'address_uuid': str(self.address.uuid),