From 85fe57d1ca84367fd1a9d680e52fae9701e6f43d Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 5 Sep 2026 15:26:16 +0330 Subject: [PATCH] Allow checkout to target selected stores instead of the whole cart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/checkout/ always converted the customer's entire multi-store cart into orders. Add an optional store_uuids field to CheckoutSerializer — when given, checkout() only processes those stores' cart groups and only clears their items, leaving the rest of the cart intact for a later checkout. Omitted, behavior is unchanged (whole cart checks out). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ apps/orders/serializers.py | 4 ++++ apps/orders/services.py | 22 ++++++++++++++++---- apps/orders/tests/test_checkout.py | 32 ++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb69be..a32d3a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/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`). +- 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 - `GET /api/v1/cities/?search=` — search cities by name (`apps/locations/views.py`); previously no text search existed. diff --git a/apps/orders/serializers.py b/apps/orders/serializers.py index e06ebd3..df2b670 100644 --- a/apps/orders/serializers.py +++ b/apps/orders/serializers.py @@ -106,6 +106,10 @@ class CheckoutSerializer(serializers.Serializer): scheduled_at = serializers.DateTimeField(required=False, allow_null=True, default=None) payment_method = serializers.ChoiceField(choices=OrderGroup.PaymentMethod.choices) 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): if attrs.get('delivery_type') == OrderGroup.DeliveryType.SCHEDULED and not attrs.get('scheduled_at'): diff --git a/apps/orders/services.py b/apps/orders/services.py index 8c1b436..5cce9ee 100644 --- a/apps/orders/services.py +++ b/apps/orders/services.py @@ -9,7 +9,7 @@ from utils.exceptions import Conflict 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): @@ -59,14 +59,27 @@ def _build_full_address(address): @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.""" +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']: @@ -134,7 +147,8 @@ def checkout(user, address, delivery_type, scheduled_at, payment_method, notes): item.product.sold_count += item.quantity 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 diff --git a/apps/orders/tests/test_checkout.py b/apps/orders/tests/test_checkout.py index 7374275..3e33e2b 100644 --- a/apps/orders/tests/test_checkout.py +++ b/apps/orders/tests/test_checkout.py @@ -33,6 +33,38 @@ class CheckoutTests(OrdersTestCase): self.product1.refresh_from_db() 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): response = self.client.post('/api/v1/checkout/', { 'address_uuid': str(self.address.uuid), -- 2.45.3