From d29ed8e35b0a59cf56e85b75428f5fa975aeaa6a Mon Sep 17 00:00:00 2001 From: Parsa Nazer Date: Sat, 15 Nov 2025 11:00:33 +0330 Subject: [PATCH] feat: add profit and special discount fields to ProductVariant model - Updated ProductVariant model to include 'profit' and 'special_discount_percent' fields. - Added corresponding fields in the admin interface for ProductVariant. - Created migration to add new fields to the database. feat: implement special discount code functionality in cart - Added composables for submitting and deleting special discount codes. - Updated CartSummary and CartItem components to handle special discount codes. - Enhanced API endpoints to support special discount operations. - Updated global types to include special discount code details in the cart. --- backend/account/admin.py | 4 + backend/account/models.py | 17 ++ backend/account/views.py | 2 +- ...038_cart_special_discount_code_and_more.py | 35 +++ ...remove_cartitem_special_discount_amount.py | 17 ++ backend/order/models.py | 107 +++++--- backend/order/serializers.py | 70 ++++- backend/order/tasks.py | 4 +- backend/order/urls.py | 4 +- backend/order/views.py | 194 +++++++++----- backend/product/admin.py | 2 +- .../0057_productvariant_profit_and_more.py | 23 ++ backend/product/models.py | 246 ++++++++++++------ .../components/cart/global/CartSummary.vue | 106 ++++++++ frontend/components/cart/index/CartItem.vue | 20 +- .../orders/useDeleteSpecialDiscountCode.ts | 25 ++ .../orders/useSubmitSpecialDiscountCode.ts | 37 +++ frontend/constants/index.ts | 2 + frontend/types/global.d.ts | 11 +- 19 files changed, 718 insertions(+), 208 deletions(-) create mode 100644 backend/order/migrations/0038_cart_special_discount_code_and_more.py create mode 100644 backend/order/migrations/0039_remove_cartitem_special_discount_amount.py create mode 100644 backend/product/migrations/0057_productvariant_profit_and_more.py create mode 100644 frontend/composables/api/orders/useDeleteSpecialDiscountCode.ts create mode 100644 frontend/composables/api/orders/useSubmitSpecialDiscountCode.ts diff --git a/backend/account/admin.py b/backend/account/admin.py index f2587f6..1248f12 100644 --- a/backend/account/admin.py +++ b/backend/account/admin.py @@ -15,7 +15,11 @@ from django.template.loader import render_to_string from folium import Map, Marker from unfold.decorators import action, display from django.utils.html import format_html +from account.models import SpecialDiscountCode +@admin.register(SpecialDiscountCode) +class SpecialDiscountCodeAdmin(ModelAdmin): + pass class UserAddressInLine(TabularInline): model = UserAddressModel diff --git a/backend/account/models.py b/backend/account/models.py index 681940e..cadf452 100644 --- a/backend/account/models.py +++ b/backend/account/models.py @@ -61,6 +61,15 @@ class User(AbstractBaseUser, PermissionsMixin): # def groups(self): # return None + + def generate_special_code(self): + """Generate and save a unique special code for the user if missing.""" + # simple deterministic code based on phone and timestamp hash + base = f"{self.phone}-{timezone.now().timestamp()}" + code = hashlib.sha256(base.encode()).hexdigest()[:12].upper() + + return code + @property def full_name(self): if self.first_name and self.last_name: @@ -120,6 +129,14 @@ class User(AbstractBaseUser, PermissionsMixin): return self.phone +class SpecialDiscountCode(models.Model): + user = models.OneToOneField(User, on_delete=models.PROTECT, related_name='spital_code') + code = models.CharField(max_length=12, unique=True) + + def __str__(self): + return f'{self.user} - {self.code}' + + class ShopModel(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='shop', verbose_name='کاربر') diff --git a/backend/account/views.py b/backend/account/views.py index 3706505..f30504b 100644 --- a/backend/account/views.py +++ b/backend/account/views.py @@ -80,7 +80,7 @@ class SendOTPView(APIView): except User.DoesNotExist: return Response({'detail': 'user not found'}, status=status.HTTP_404_NOT_FOUND) except Exception as e: - return Response({'detail': f'error: {e} مشتی فعلا برو تو غمت نباشه تا بعدا یه کاریش بکنم', 'otp_code': otp}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response({'detail': f'error: {e} مشتی فعلا برو تو غمت نباشه تا بعدا یه کاریش بکنم', 'otp_code': otp}, status=status.HTTP_200_OK) # return Response({'detail': f'An error occurred: {e}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) @extend_schema_view( post=extend_schema(tags=['authentication']) diff --git a/backend/order/migrations/0038_cart_special_discount_code_and_more.py b/backend/order/migrations/0038_cart_special_discount_code_and_more.py new file mode 100644 index 0000000..61cee19 --- /dev/null +++ b/backend/order/migrations/0038_cart_special_discount_code_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.2 on 2025-11-14 14:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0032_specialdiscountcode'), + ('order', '0037_ordermodel_is_stock_rolled_back'), + ] + + operations = [ + migrations.AddField( + model_name='cart', + name='special_discount_code', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='account.specialdiscountcode', verbose_name='کدتخفیف خاص'), + ), + migrations.AddField( + model_name='cartitem', + name='special_discount_amount', + field=models.BigIntegerField(default=0, help_text='تخفیف محاسبه شده از سود تنوع', verbose_name='مقدار تخفیف ویژه'), + ), + migrations.AddField( + model_name='orderitemmodel', + name='special_discount_amount', + field=models.BigIntegerField(default=0, verbose_name='مقدار تخفیف ویژه'), + ), + migrations.AddField( + model_name='ordermodel', + name='special_discount_total', + field=models.BigIntegerField(blank=True, null=True, verbose_name='مجموع تخفیف ویژه'), + ), + ] diff --git a/backend/order/migrations/0039_remove_cartitem_special_discount_amount.py b/backend/order/migrations/0039_remove_cartitem_special_discount_amount.py new file mode 100644 index 0000000..75a4e40 --- /dev/null +++ b/backend/order/migrations/0039_remove_cartitem_special_discount_amount.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2025-11-14 15:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0038_cart_special_discount_code_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='cartitem', + name='special_discount_amount', + ), + ] diff --git a/backend/order/models.py b/backend/order/models.py index 1fcebfe..7dd21ba 100644 --- a/backend/order/models.py +++ b/backend/order/models.py @@ -1,3 +1,4 @@ +from account.models import SpecialDiscountCode from django.db import models, transaction from account.models import User, UserAddressModel, PushSubscription from product.models import ProductModel, ProductVariant, ProductImageModel @@ -47,6 +48,8 @@ class Cart(models.Model): related_name='carts', null=True, verbose_name='ادرس') discount_code = models.ForeignKey( DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف") + special_discount_code = models.ForeignKey( + SpecialDiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف خاص") class Meta: verbose_name = 'سبد خرید' @@ -58,6 +61,7 @@ class Cart(models.Model): def clear_cart(self): self.items.all().delete() self.discount_code = None + self.special_discount_code = None self.save() @property @@ -71,10 +75,17 @@ class Cart(models.Model): def items_discount_amount(self): return int(sum(item.item_discount_amount for item in self.items.all())) + @property + def special_discount_total(self): + """Sum of all special discounts from cart items when special_discount_code is applied.""" + if self.special_discount_code: + return int(sum(item.item_special_discount_amount for item in self.items.all())) + return 0 + @property def total_before_tax(self): - return self.cart_total - (self.discount_code_amount + self.items_discount_amount) - + return self.cart_total - (self.discount_code_amount + self.items_discount_amount + self.special_discount_total) + @property def tax_amount(self): return int(self.total_before_tax * settings.DEFAULT_TAX_RATE / 100) @@ -102,6 +113,7 @@ class CartItem(models.Model): quantity = models.PositiveIntegerField(default=1) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + # special_discount_amount = models.BigIntegerField(default=0, verbose_name='مقدار تخفیف ویژه', help_text='تخفیف محاسبه شده از سود تنوع') class Meta: verbose_name = 'ایتم سبد خرید' @@ -111,22 +123,37 @@ class CartItem(models.Model): def __str__(self): return f"{self.quantity} x {self.product_variant.product.name} in cart {self.cart.id}" + @property + def special_discount_amount(self): + """Calculate special discount for this cart item based on variant profit and special_discount_percent.""" + if hasattr(self.cart, 'special_discount_code') and self.cart.special_discount_code: + return self.product_variant.special_discount_amount_per_unit * self.quantity + return 0 + @property def price_before_discount(self): return self.quantity * self.product_variant.price_before_discount - + @property def item_discount_amount(self): return self.product_variant.discount_amount * self.quantity - + + @property + def item_special_discount_amount(self): + """Calculate special discount for this cart item based on variant profit and special_discount_percent.""" + if hasattr(self.cart, 'special_discount_code') and self.cart.special_discount_code: + return self.product_variant.special_discount_amount_per_unit * self.quantity + return 0 + @property def price_after_discount(self): - return self.price_before_discount - self.item_discount_amount + return self.price_before_discount - self.item_discount_amount - self.item_special_discount_amount @property def discount(self): return self.product_variant.discount + class OrderModel(models.Model): objects = jmodels.jManager() STATUS_CHOICES = [ @@ -155,9 +182,12 @@ class OrderModel(models.Model): null=True, blank=True, verbose_name='قیمت نهایی') cart_total = models.BigIntegerField( null=True, blank=True, verbose_name='کل سبد خرید') + special_discount_total = models.BigIntegerField( + null=True, blank=True, verbose_name='مجموع تخفیف ویژه') + + cart = models.ForeignKey( + Cart, on_delete=models.CASCADE, null=True, blank=True) - cart = models.ForeignKey(Cart, on_delete=models.CASCADE, null=True, blank=True) - is_stock_rolled_back = models.BooleanField( default=False, verbose_name="موجودی برگردانده شده") @@ -169,36 +199,37 @@ class OrderModel(models.Model): verbose_name_plural = 'سفارشات' def rollback_stock(self): - """ - Rollback stock quantities for all items in this order - Returns True if successful, False otherwise - """ - if self.is_stock_rolled_back: - return False - - # if not self.cart: - # return False - - try: - # Get all cart items and rollback their stock - for order_item in self.items.all(): - product = order_item.product - # Add back the quantity to stock - product.in_stock += order_item.quantity - product.save() - - # Mark as rolled back - self.is_stock_rolled_back = True - self.save(update_fields=['is_stock_rolled_back']) - self.status = 'CANCELED' - self.save() - return True - - except Exception as e: - print(e) - # Log the error if you have logging setup - # logger.error(f"Failed to rollback stock for order {self.pk}: {e}") - return False + """ + Rollback stock quantities for all items in this order + Returns True if successful, False otherwise + """ + if self.is_stock_rolled_back: + return False + + # if not self.cart: + # return False + + try: + # Get all cart items and rollback their stock + for order_item in self.items.all(): + product = order_item.product + # Add back the quantity to stock + product.in_stock += order_item.quantity + product.save() + + # Mark as rolled back + self.is_stock_rolled_back = True + self.save(update_fields=['is_stock_rolled_back']) + self.status = 'CANCELED' + self.save() + return True + + except Exception as e: + print(e) + # Log the error if you have logging setup + # logger.error(f"Failed to rollback stock for order {self.pk}: {e}") + return False + class OrderItemModel(models.Model): order = models.ForeignKey( @@ -208,6 +239,8 @@ class OrderItemModel(models.Model): product = models.ForeignKey( ProductVariant, on_delete=models.PROTECT, verbose_name="محصول") discount_percent = models.SmallIntegerField(verbose_name='درصد تخفیف') + special_discount_amount = models.BigIntegerField( + default=0, verbose_name='مقدار تخفیف ویژه') class Meta: verbose_name = 'ایتم سبد خرید' diff --git a/backend/order/serializers.py b/backend/order/serializers.py index b2e95bd..1c62795 100644 --- a/backend/order/serializers.py +++ b/backend/order/serializers.py @@ -1,9 +1,11 @@ +from django.conf import settings from rest_framework import serializers from .models import OrderItemModel, OrderModel, DiscountCode, Cart, CartItem from product.serializers import ProductVariantSerialzier, AttributeValueSerialzier, ProductImageSerailizer from account.serializers import UserAddressSerializer from product.models import ProductVariant + class ProductVariantSerialzier(serializers.ModelSerializer): product_attributes = AttributeValueSerialzier(many=True) image = serializers.SerializerMethodField() @@ -13,12 +15,14 @@ class ProductVariantSerialzier(serializers.ModelSerializer): final_price = serializers.SerializerMethodField() category = serializers.SerializerMethodField() slug = serializers.CharField(source='product.slug') + class Meta: model = ProductVariant - fields = ['id', 'slug', 'title', 'product_attributes', 'in_stock', 'price', 'discount', 'color', 'image', 'discount_amount', 'category', 'final_price'] + fields = ['id', 'slug', 'title', 'product_attributes', 'in_stock', 'price', + 'discount', 'color', 'image', 'discount_amount', 'category', 'final_price'] def get_discount_amount(self, obj): - discount_amount = int(obj.price * (obj.discount / 100)) + discount_amount = int(obj.price * (obj.discount / 100)) return f'{discount_amount:,.0f} تومان' def get_final_price(self, obj): @@ -51,10 +55,13 @@ class OrderItemSerailzier(serializers.ModelSerializer): price = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField() discount = serializers.SerializerMethodField() + special_discount_amount = serializers.SerializerMethodField() + class Meta: model = CartItem exclude = ('cart',) read_only_fields = ('cart', 'product', 'discount_percent') + def get_product(self, obj): return ProductVariantSerialzier(instance=obj.product_variant, context={'request': self.context.get('request')}).data @@ -70,7 +77,20 @@ class OrderItemSerailzier(serializers.ModelSerializer): def get_discount(self, obj): return obj.product_variant.discount -from django.conf import settings + def get_special_discount_amount(self, obj): + # For cart items + print('in here asdfasfd') + amount = getattr(obj, 'special_discount_amount', None) + print(amount) + if amount is None: + print('in here') + # If it's an order item, check item_special_discount_amount property + amount = getattr(obj, 'item_special_discount_amount', 0) + if amount is None: + amount = 0 + print('in here ') + return f'{int(amount):,.0f} تومان' + class CartSerializer(serializers.ModelSerializer): items = OrderItemSerailzier(many=True) @@ -79,11 +99,23 @@ class CartSerializer(serializers.ModelSerializer): final_price = serializers.SerializerMethodField() discount_code = serializers.SerializerMethodField() items_discount_amount = serializers.SerializerMethodField() + special_discount_total = serializers.SerializerMethodField() + special_discount_code = serializers.SerializerMethodField() address = UserAddressSerializer() + class Meta: model = Cart - fields = ['items_discount_amount', 'discount_code', 'items', 'cart_total', 'tax_amount', 'final_price', 'address'] + fields = ['items_discount_amount', 'discount_code', 'items', 'cart_total', 'tax_amount', + 'final_price', 'address', 'special_discount_total', 'special_discount_code'] + def get_special_discount_code(self, obj): + if obj.special_discount_code: + return { + 'code': f'{obj.special_discount_code.code}', + 'user': f'{obj.special_discount_code.user.phone}' + } + else: + return None def get_discount_code(self, obj): if obj.discount_code: @@ -95,7 +127,6 @@ class CartSerializer(serializers.ModelSerializer): else: return None - def get_tax_amount(self, obj): return f'{obj.tax_amount:,.0f} تومان' @@ -107,17 +138,26 @@ class CartSerializer(serializers.ModelSerializer): def get_final_price(self, obj): return f'{obj.final_price:,.0f} تومان' - + + def get_special_discount_total(self, obj): + # sum of special discounts on cart items + total = obj.special_discount_total if hasattr( + obj, 'special_discount_total') else 0 + return f'{int(total):,.0f} تومان' + class OrderListSerializer(serializers.ModelSerializer): - count = serializers.SerializerMethodField() - images = serializers.SerializerMethodField() + count = serializers.SerializerMethodField() + images = serializers.SerializerMethodField() verbose_status = serializers.SerializerMethodField() order_id = serializers.SerializerMethodField() + class Meta: model = OrderModel - fields = ['created_at', 'status', "images", "count", "id", 'final_price', 'order_id', 'verbose_status'] + fields = ['created_at', 'status', "images", "count", + "id", 'final_price', 'order_id', 'verbose_status'] read_only_fields = ['count', 'images', 'order_id', 'verbose_status'] + def get_verbose_status(self, obj): return obj.get_status_display() @@ -126,6 +166,7 @@ class OrderListSerializer(serializers.ModelSerializer): def get_order_id(self, obj): return obj.pk + 1000 + def get_images(self, obj): image_list = [ self.context.get('request').build_absolute_uri(image.image.url) @@ -136,16 +177,18 @@ class OrderListSerializer(serializers.ModelSerializer): class OrderGetSerializer(serializers.ModelSerializer): - count = serializers.SerializerMethodField() - images = serializers.SerializerMethodField() + count = serializers.SerializerMethodField() + images = serializers.SerializerMethodField() order_id = serializers.SerializerMethodField() verbose_status = serializers.SerializerMethodField() items = OrderItemSerailzier(many=True) address = UserAddressSerializer() discount_code = DiscountCodeSerializer() + class Meta: model = OrderModel - fields = ['created_at', 'status', "images", "count", "id", 'final_price', 'order_id', 'verbose_status', 'address', 'items', 'tax' , 'cart_total', 'discount_code', 'discount_amount'] + fields = ['created_at', 'status', "images", "count", "id", 'final_price', 'order_id', 'verbose_status', + 'address', 'items', 'tax', 'cart_total', 'discount_code', 'discount_amount', 'special_discount_total'] def get_verbose_status(self, obj): return obj.get_status_display() @@ -160,5 +203,6 @@ class OrderGetSerializer(serializers.ModelSerializer): for item in obj.items.all()[:3] ] return filter(lambda x: x is not None, image_list) + def get_order_id(self, obj): - return obj.id + 1000 \ No newline at end of file + return obj.id + 1000 diff --git a/backend/order/tasks.py b/backend/order/tasks.py index 0133e83..aa2d481 100644 --- a/backend/order/tasks.py +++ b/backend/order/tasks.py @@ -7,7 +7,7 @@ from azbankgateways import ( from .models import OrderModel from account.models import PushSubscription import ghasedak_sms - +from product.models import ProductImageModel from celery import shared_task @shared_task @@ -24,6 +24,8 @@ def udpate_bank_status(): bank_record = bank_models.Bank.objects.get(tracking_code=item.tracking_code) if bank_record.is_success: bank_record.order.cart.clear_cart() + bank_record.order.is_paid = True + bank_record.order.save() logging.debug("This record is verify now.", extra={"pk": bank_record.pk}) else: order = bank_record.order diff --git a/backend/order/urls.py b/backend/order/urls.py index 8e63618..04c9b69 100644 --- a/backend/order/urls.py +++ b/backend/order/urls.py @@ -8,9 +8,11 @@ urlpatterns = [ path('cart', CartView.as_view()), path('cart/set-address', SetAddressForCartView.as_view()), path('cart/discount', ApplyDiscountView.as_view()), + path('cart/special-discount', ApplySpecialDiscountView.as_view()), path('cart/all', CartItemClear.as_view()), path('cart/item/', CartItemViews.as_view(), name='change-item-cart'), path('cart/payment', PaymentView.as_view(), name='payment'), - path('transaction/', CallbackView.as_view(), name='callback-gateway'), + path('transaction/', + CallbackView.as_view(), name='callback-gateway'), path('', OrderGetView.as_view(), name='order-get'), ] diff --git a/backend/order/views.py b/backend/order/views.py index 8a07ba4..4fcd28c 100644 --- a/backend/order/views.py +++ b/backend/order/views.py @@ -1,10 +1,19 @@ +from django.db import transaction +from order.models import Cart +from django.utils.translation import override +from .permissons import PaymentCallBackPermissions +from azbankgateways.models.enum import PaymentStatus +from azbankgateways.models import Bank +from rest_framework import serializers +from rest_framework.response import Response +from django.utils import timezone from django.shortcuts import render from rest_framework.views import APIView, Response from django.shortcuts import get_object_or_404 from product.models import ProductVariant from rest_framework.permissions import IsAuthenticated from .serializers import * -# from cart.models import +# from cart.models import from rest_framework import status from .models import OrderItemModel, OrderModel, DiscountCode from .permissons import CanDeleteCartItemPermissions, GetOrderPermission, SetAddressPermissions @@ -30,12 +39,14 @@ from account.models import UserAddressModel class ApplyDiscountView(APIView): serializer_class = DiscountCodeSerializer permission_classes = [IsAuthenticated] + def post(self, request): cart_order, created = Cart.objects.get_or_create( user=request.user, ) - discount_code = get_object_or_404(DiscountCode, code=request.data.get('code')) - + discount_code = get_object_or_404( + DiscountCode, code=request.data.get('code')) + if not discount_code.is_valid(): return Response({'detail': discount_code.not_valid_reason()}, status=status.HTTP_400_BAD_REQUEST) cart_order.discount_code = discount_code @@ -46,13 +57,49 @@ class ApplyDiscountView(APIView): cart_order, created = Cart.objects.get_or_create( user=request.user, ) - cart_order.discount_code = None + cart_order.discount_code = None cart_order.save() return Response({'detail': 'کد تخفیف با موفقیت حذف شد'}, status=status.HTTP_204_NO_CONTENT) + +@extend_schema_view( + post=extend_schema(tags=["cart special discount code"]), + delete=extend_schema(tags=["cart special discount code"]), +) +class ApplySpecialDiscountView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request): + from account.models import SpecialDiscountCode + + cart, created = Cart.objects.get_or_create(user=request.user) + code = request.data.get('code') + + if not code: + return Response({'detail': 'کد تخفیف ویژه را وارد کنید'}, status=status.HTTP_400_BAD_REQUEST) + + try: + special_discount_code = SpecialDiscountCode.objects.get(code=code) + except SpecialDiscountCode.DoesNotExist: + return Response({'detail': 'کد تخفیف ویژه معتبر نیست'}, status=status.HTTP_404_NOT_FOUND) + + # Apply the special discount code to cart + cart.special_discount_code = special_discount_code + cart.save() + + return Response({'detail': 'کد تخفیف ویژه با موفقیت اعمال شد'}, status=status.HTTP_200_OK) + + def delete(self, request): + cart, created = Cart.objects.get_or_create(user=request.user) + cart.special_discount_code = None + cart.save() + return Response({'detail': 'کد تخفیف ویژه با موفقیت حذف شد'}, status=status.HTTP_204_NO_CONTENT) + + class CartItemClear(APIView): permission_classes = [IsAuthenticated] serializer_class = OrderItemSerailzier + @extend_schema( tags=["order cart"] ) @@ -63,6 +110,7 @@ class CartItemClear(APIView): cart_order.items.all().delete() return Response({'detail': f'سبد خرید با موفقیت خالی شد'}, status=status.HTTP_204_NO_CONTENT) + @extend_schema_view( post=extend_schema(tags=["order cart"]), delete=extend_schema(tags=["order cart"]), @@ -70,6 +118,7 @@ class CartItemClear(APIView): class CartItemViews(APIView): permission_classes = [IsAuthenticated] serializer_class = OrderItemSerailzier + def post(self, request, pk): product_variant = get_object_or_404(ProductVariant, pk=pk) response = 'محصول با موفقیت به سبد خرید اضافه شد' @@ -80,15 +129,15 @@ class CartItemViews(APIView): response = 'تعداد درخواستی بیشتر از موجودی محصول میباشد' cart_order, created = Cart.objects.get_or_create(user=request.user) - order_item, created = CartItem.objects.get_or_create(cart=cart_order, product_variant=product_variant, defaults={'quantity': quantity}) - if not created and order_item.quantity: + order_item, created = CartItem.objects.get_or_create( + cart=cart_order, product_variant=product_variant, defaults={'quantity': quantity}) + if not created and order_item.quantity: order_item.quantity = quantity order_item.save() if not order_item.quantity: order_item.delete() return Response({'detail': response, 'count': quantity}, status=status.HTTP_202_ACCEPTED) - - + def delete(self, request, pk): order_item = get_object_or_404(OrderItemModel, pk=pk) permission = CanDeleteCartItemPermissions() @@ -102,17 +151,19 @@ class CartItemViews(APIView): status=status.HTTP_204_NO_CONTENT, ) -from order.models import Cart + class CartView(APIView): permission_classes = [IsAuthenticated] serializer_class = CartSerializer + @extend_schema( tags=["order cart"] ) def get(self, request): user = request.user cart_instance, created = Cart.objects.get_or_create(user=user) - cart_ser = self.serializer_class(instance=cart_instance, context={'request': request}) + cart_ser = self.serializer_class( + instance=cart_instance, context={'request': request}) return Response(cart_ser.data, status=status.HTTP_200_OK) @@ -120,6 +171,7 @@ class OrderlistView(APIView): permission_classes = [IsAuthenticated] serializer_class = OrderListSerializer pagination_class = StructurePagination + @extend_schema( parameters=[ OpenApiParameter( @@ -160,7 +212,7 @@ class OrderlistView(APIView): orders = OrderModel.objects.filter(user=user).exclude(status="CART") status_filter = request.query_params.get("status", None) sort = request.query_params.get('sort', None) - if status_filter in [ 'ADMIN_PENDING', 'PENDING', 'POSTED', 'RECEIVED', 'CANCELED', 'REFUNDED']: + if status_filter in ['ADMIN_PENDING', 'PENDING', 'POSTED', 'RECEIVED', 'CANCELED', 'REFUNDED']: orders.filter(status=status_filter) if sort: if sort not in ['created_at', '-created_at', 'final_price', '-final_price']: @@ -168,62 +220,59 @@ class OrderlistView(APIView): orders = orders.order_by(sort) paginator = self.pagination_class() paginated_orders = paginator.paginate_queryset(orders, request) - orders_ser = self.serializer_class(instance=paginated_orders, many=True, context={'request': request}) + orders_ser = self.serializer_class( + instance=paginated_orders, many=True, context={'request': request}) return paginator.get_paginated_response(orders_ser.data) - class OrderGetView(APIView): permission_classes = [IsAuthenticated, GetOrderPermission] serializer_class = OrderGetSerializer + def get(self, request, pk): order_object = get_object_or_404(OrderModel, pk=pk) - permission = GetOrderPermission() if not permission.has_object_permission(request, self, order_object): return Response({"detail": permission.message}, status=status.HTTP_403_FORBIDDEN) - - order_ser = self.serializer_class(order_object, context={'request': request}) + order_ser = self.serializer_class( + order_object, context={'request': request}) return Response(order_ser.data, status=status.HTTP_200_OK) - -from rest_framework import serializers - class BankTypeSerializer(serializers.Serializer): - gateway_type = serializers.ChoiceField(choices=['ZIBAL', 'BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'BAHAMTA', 'MELLAT', 'PAYV1']) + gateway_type = serializers.ChoiceField( + choices=['ZIBAL', 'BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'BAHAMTA', 'MELLAT', 'PAYV1']) + -from django.db import transaction -from django.utils import timezone class PaymentView(APIView): permission_classes = [IsAuthenticated] serializer_class = BankTypeSerializer + @extend_schema( description="choices=['BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'ZIBAL', 'BAHAMTA', 'MELLAT', 'PAYV1']", tags=['order payment'] ) def post(self, request): - # Get user's cart cart = get_object_or_404(Cart, user=request.user) - + # Check if cart has items if not cart.items.exists(): return Response( - {'error': 'سبد خرید خالی است'}, + {'error': 'سبد خرید خالی است'}, status=status.HTTP_400_BAD_REQUEST ) - + # Check if cart has address if not cart.address: return Response( - {'error': 'آدرس انتخاب نشده است'}, + {'error': 'آدرس انتخاب نشده است'}, status=status.HTTP_400_BAD_REQUEST ) - + # Validate product variant quantities insufficient_stock_items = [] adjusted_items = [] @@ -231,7 +280,7 @@ class PaymentView(APIView): for cart_item in cart.items.all(): if cart_item.product_variant.in_stock < cart_item.quantity: available_stock = cart_item.product_variant.in_stock - + # Store info about insufficient stock insufficient_stock_items.append({ 'product': cart_item.product_variant.product.name, @@ -239,7 +288,7 @@ class PaymentView(APIView): 'requested': cart_item.quantity, 'available': available_stock }) - + # Auto-adjust the cart item quantity if available_stock > 0: # Reduce quantity to available stock @@ -264,20 +313,25 @@ class PaymentView(APIView): if insufficient_stock_items: # Create error message with product names - product_names = [item['product'] for item in insufficient_stock_items] + product_names = [item['product'] + for item in insufficient_stock_items] product_list = '، '.join(product_names) - + return Response({ 'detail': f'موجودی محصولات زیر کافی نیست: {product_list}', 'message': 'تعداد محصولات به صورت خودکار تنظیم شد', 'insufficient_items': insufficient_stock_items, 'adjusted_items': adjusted_items }, status=status.HTTP_400_BAD_REQUEST) - - try: with transaction.atomic(): + # Compute special discounts for cart items if special_discount_code is applied + special_total = 0 + for cart_item in cart.items.select_related('product_variant').all(): + if cart.special_discount_code: + special_total += cart_item.special_discount_amount + # Create order order = OrderModel.objects.create( user=request.user, @@ -285,13 +339,14 @@ class PaymentView(APIView): created_at=timezone.now().date(), discount_code=cart.discount_code, discount_amount=cart.discount_code_amount, + special_discount_total=special_total, tax=cart.tax_amount, final_price=cart.final_price, cart_total=cart.cart_total, status='ADMIN_PENDING', cart=cart ) - + # Create order items and reduce product variant quantities for cart_item in cart.items.all(): OrderItemModel.objects.create( @@ -299,71 +354,64 @@ class PaymentView(APIView): quantity=cart_item.quantity, price=cart_item.product_variant.price, product=cart_item.product_variant, - discount_percent=cart_item.discount + discount_percent=cart_item.discount, + special_discount_amount=cart_item.special_discount_amount ) - + # Reduce product variant quantity cart_item.product_variant.in_stock -= cart_item.quantity cart_item.product_variant.save() - + # Reduce discount code quantity if used if cart.discount_code: cart.discount_code.quantity -= 1 cart.discount_code.save() - + # Setup payment gateway user_mobile_number = request.user.phone factory = bankfactories.BankFactory() - + bank = factory.create(bank_models.BankType.ZIBAL) bank.set_request(request) - bank.set_amount(cart.final_price) # Use final_price instead of hardcoded amount - bank.set_client_callback_url('http://localhost:3000/transaction') + # Use final_price instead of hardcoded amount + bank.set_amount(cart.final_price) + bank.set_client_callback_url( + 'http://localhost:3000/transaction') bank.set_mobile_number(user_mobile_number) - + bank_record = bank.ready() # Link bank record to order (assuming you have this relationship) bank_record.order = order bank_record.save() - return Response({ 'url': bank.get_gateway()['url'], }) - + except AZBankGatewaysException as e: print(f"Payment gateway error: {e}") return Response({ 'error': 'خطا در اتصال به درگاه پرداخت' }, status=status.HTTP_400_BAD_REQUEST) - + except Exception as e: print(f"Order creation error: {e}") return Response({ 'error': 'خطا در ثبت سفارش' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - - - - -from rest_framework.response import Response -from azbankgateways import bankfactories, models as bank_models -from rest_framework import serializers -from azbankgateways.models import Bank -from azbankgateways.models.enum import PaymentStatus -from .permissons import PaymentCallBackPermissions -from django.utils.translation import override class BankCallbackSerializer(serializers.ModelSerializer): status_detail = serializers.SerializerMethodField() bank_type = serializers.SerializerMethodField() amount = serializers.SerializerMethodField() status = serializers.SerializerMethodField() + class Meta: model = Bank - fields = ['status', 'bank_type', 'tracking_code', 'amount', 'created_at', 'response_result', 'reference_number', 'status_detail'] + fields = ['status', 'bank_type', 'tracking_code', 'amount', + 'created_at', 'response_result', 'reference_number', 'status_detail'] + def get_status_detail(self, obj): with override('fa'): return obj.get_status_display() @@ -371,8 +419,10 @@ class BankCallbackSerializer(serializers.ModelSerializer): def get_bank_type(self, obj): with override('fa'): return obj.get_bank_type_display() + def get_amount(self, obj): return f'{int(obj.amount):,.0f} تومان' + def get_status(self, obj): if obj.status in { PaymentStatus.WAITING, @@ -395,25 +445,31 @@ class BankCallbackSerializer(serializers.ModelSerializer): class CallbackView(APIView): serializer_class = BankCallbackSerializer permission_classes = [IsAuthenticated] + def get(self, request, tracking_code): if not tracking_code: return Response({'detail': 'تریسکد خالی است.'}, status=status.HTTP_400_BAD_REQUEST) try: - bank_record = bank_models.Bank.objects.get(tracking_code=tracking_code) + bank_record = bank_models.Bank.objects.get( + tracking_code=tracking_code) permission = PaymentCallBackPermissions() if not permission.has_object_permission(request, self, bank_record): return Response({"detail": permission.message}, status=status.HTTP_403_FORBIDDEN) - - bank_record_ser = self.serializer_class(instance=bank_record, context={'request': request}) - + + bank_record_ser = self.serializer_class( + instance=bank_record, context={'request': request}) + except bank_models.Bank.DoesNotExist: return Response({'detail': 'کد تریسکد معتبر نمیباشد.'}, status=status.HTTP_404_NOT_FOUND) if bank_record.is_success: - bank_record.order.cart.clear_cart() - return Response({"detail" : "پرداخت با موفقیت انجام شد.", "bank_result": bank_record_ser.data}, status=status.HTTP_200_OK) + order = bank_record.order + order.cart.clear_cart() + order.is_paid = True + order.save() + return Response({"detail": "پرداخت با موفقیت انجام شد.", "bank_result": bank_record_ser.data}, status=status.HTTP_200_OK) else: order = bank_record.order order.rollback_stock() @@ -428,10 +484,12 @@ class CallbackView(APIView): class SetAddressSerilizer(serializers.Serializer): address_id = serializers.IntegerField() -from order.models import Cart + + class SetAddressForCartView(APIView): serializer_class = SetAddressSerilizer permission_classes = [IsAuthenticated, SetAddressPermissions] + @extend_schema( tags=["order cart"] ) @@ -443,10 +501,10 @@ class SetAddressForCartView(APIView): permission = SetAddressPermissions() if not permission.has_object_permission(request, self, address_object): return Response({"detail": permission.message}, status=status.HTTP_403_FORBIDDEN) - + cart_order, created = Cart.objects.get_or_create( user=request.user, ) cart_order.address = address_object cart_order.save() - return Response({'detail': 'ادرس با موفقیت انتخاب شد'}) \ No newline at end of file + return Response({'detail': 'ادرس با موفقیت انتخاب شد'}) diff --git a/backend/product/admin.py b/backend/product/admin.py index 98cc1e4..7a8ab0c 100644 --- a/backend/product/admin.py +++ b/backend/product/admin.py @@ -185,7 +185,7 @@ class ProductVariantInLine(StackedInline): readonly_fields = ['price'] # inlines = [DetailModelInLine] autocomplete_fields = ['product_attributes', 'in_pack_items', 'images', 'details'] - fields = ['images', 'video','input_price', 'min_price', 'currency', 'price', 'discount','in_stock', 'color', 'product_attributes', 'in_pack_items', 'details', 'sell', 'slider_category'] + fields = ['images', 'video','input_price', 'min_price', 'currency', 'price', 'discount','in_stock', 'color', 'product_attributes', 'in_pack_items', 'details', 'sell', 'slider_category', 'profit', 'special_discount_percent'] # search_fields = [''] diff --git a/backend/product/migrations/0057_productvariant_profit_and_more.py b/backend/product/migrations/0057_productvariant_profit_and_more.py new file mode 100644 index 0000000..74d5f9c --- /dev/null +++ b/backend/product/migrations/0057_productvariant_profit_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-11-14 14:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0056_productmodel_image'), + ] + + operations = [ + migrations.AddField( + model_name='productvariant', + name='profit', + field=models.BigIntegerField(default=0, help_text='مقدار سود به ازای هر واحد به تومان', verbose_name='سود (تومان)'), + ), + migrations.AddField( + model_name='productvariant', + name='special_discount_percent', + field=models.SmallIntegerField(default=0, help_text='درصدی که از سود برای محاسبه تخفیف ویژه استفاده می\u200cشود', verbose_name='درصد تخفیف ویژه'), + ), + ] diff --git a/backend/product/models.py b/backend/product/models.py index ed02b99..cc994e2 100644 --- a/backend/product/models.py +++ b/backend/product/models.py @@ -6,14 +6,23 @@ import requests from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError from home.models import ShowCaseSlider + + class MainCategoryModel(models.Model): name = models.CharField(max_length=50, verbose_name='نام دسته بندی') - slug = models.SlugField(max_length=50, unique=True, help_text="اسم دسته را برای مسیر به انگلیسی و بدون فاصله وارد کنید") - icon = models.ImageField(upload_to='category_model/',verbose_name='آیکون', blank=True, null=True) - image = models.ImageField(upload_to='category_model/',verbose_name='عکس', blank=True, null=True) - meta_title = models.CharField(max_length=60, verbose_name="عنوان متا", help_text="عنوان متا برای SEO", blank=True, null=True) - meta_description = models.TextField(max_length=160, verbose_name="توضیحات متا", help_text="توضیحات متا برای SEO", blank=True, null=True) - video = models.FileField(upload_to='category_videos/', blank=True, null=True, verbose_name='ویدیو') + slug = models.SlugField(max_length=50, unique=True, + help_text="اسم دسته را برای مسیر به انگلیسی و بدون فاصله وارد کنید") + icon = models.ImageField(upload_to='category_model/', + verbose_name='آیکون', blank=True, null=True) + image = models.ImageField( + upload_to='category_model/', verbose_name='عکس', blank=True, null=True) + meta_title = models.CharField( + max_length=60, verbose_name="عنوان متا", help_text="عنوان متا برای SEO", blank=True, null=True) + meta_description = models.TextField( + max_length=160, verbose_name="توضیحات متا", help_text="توضیحات متا برای SEO", blank=True, null=True) + video = models.FileField(upload_to='category_videos/', + blank=True, null=True, verbose_name='ویدیو') + class Meta: verbose_name = "دسته‌بندی اصلی" verbose_name_plural = "دسته‌بندی‌هااصلی" @@ -32,13 +41,19 @@ class MainCategoryModel(models.Model): class SubCategoryModel(models.Model): name = models.CharField(max_length=50, verbose_name='نام دسته بندی') - slug = models.SlugField(max_length=50, unique=True, help_text="اسم دسته را برای مسیر به انگلیسی و بدون فاصله وارد کنید") - image = models.ImageField(upload_to='category_model/',verbose_name='عکس', blank=True, null=True) - icon = models.ImageField(upload_to='category_model/',verbose_name='آیکون', blank=True, null=True) - meta_title = models.CharField(max_length=60, verbose_name="عنوان متا", help_text="عنوان متا برای SEO", blank=True, null=True) - meta_description = models.TextField(max_length=160, verbose_name="توضیحات متا", help_text="توضیحات متا برای SEO", blank=True, null=True) - parent = models.ForeignKey(MainCategoryModel, on_delete=models.CASCADE, related_name='subcategorys', verbose_name='دسته‌بندی والد') - + slug = models.SlugField(max_length=50, unique=True, + help_text="اسم دسته را برای مسیر به انگلیسی و بدون فاصله وارد کنید") + image = models.ImageField( + upload_to='category_model/', verbose_name='عکس', blank=True, null=True) + icon = models.ImageField(upload_to='category_model/', + verbose_name='آیکون', blank=True, null=True) + meta_title = models.CharField( + max_length=60, verbose_name="عنوان متا", help_text="عنوان متا برای SEO", blank=True, null=True) + meta_description = models.TextField( + max_length=160, verbose_name="توضیحات متا", help_text="توضیحات متا برای SEO", blank=True, null=True) + parent = models.ForeignKey(MainCategoryModel, on_delete=models.CASCADE, + related_name='subcategorys', verbose_name='دسته‌بندی والد') + class Meta: verbose_name = "زیر دسته‌بندی" verbose_name_plural = "زیر دسته‌بندی‌ها" @@ -49,19 +64,25 @@ class SubCategoryModel(models.Model): def __str__(self): return self.name + def save(self, *args, **kwargs): if not self.slug: self.slug = slugify(self.name, allow_unicode=True) super().save(*args, **kwargs) - + + class DollorModel(models.Model): price = models.FloatField(null=True, blank=True, verbose_name='قیمت دلار') - defualt_price = models.FloatField(null=True, blank=True, default=80000.0, verbose_name='قیمت دستی') + defualt_price = models.FloatField( + null=True, blank=True, default=80000.0, verbose_name='قیمت دستی') # these fields will avoid dublicate of this model unique = (('unique', 'unique'),) - unique_filed = models.CharField(max_length=20, choices=unique, unique=True, default='unique') + unique_filed = models.CharField( + max_length=20, choices=unique, unique=True, default='unique') + def __str__(self): return str(self.price) + def save(self, *args, **kwargs): if not self.price: self.update_price() @@ -82,23 +103,25 @@ class DollorModel(models.Model): if self.price: print('\n\nprice from last price \n\n') return self.price - + else: print('\n\nprice from defualt price \n\n') return self.defualt_price return price_in_usd - + class Meta: verbose_name = 'مدل دلار' verbose_name_plural = 'مدل دلار' indexes = [ - models.Index(fields=['unique_filed'], name='dollor_unique_field_idx'), + models.Index(fields=['unique_filed'], + name='dollor_unique_field_idx'), ] class InPackItems(models.Model): item_title = models.CharField(max_length=50) - cover = models.ImageField(upload_to='product_items/', verbose_name='کاور ایتم') + cover = models.ImageField( + upload_to='product_items/', verbose_name='کاور ایتم') class Meta: verbose_name = 'ایتم داخل پک' @@ -117,15 +140,24 @@ class ProductModel(models.Model): view = models.IntegerField(default=0, verbose_name='بازدید') slug = models.SlugField(max_length=255, unique=True, blank=True, null=True, allow_unicode=True, verbose_name='نام یکتا', help_text="این فیلد را خالی بگذارید") - meta_description = models.CharField(max_length=300, blank=True, null=True, help_text='این فیلد را حتما پر کنید', verbose_name='متا دیسکریپشن') - meta_keywords = models.CharField(max_length=300, blank=True, null=True, help_text='این فیلد را حتما پر کنید', verbose_name='متا کیورد') - meta_rating = models.FloatField(default=5, help_text='امتیاز محصول', verbose_name='متا ریتینگ') - created_at = models.DateTimeField(auto_now_add=True, verbose_name='زمان ثبت محصول') - category = models.ForeignKey(SubCategoryModel, null=True, on_delete=models.SET_NULL, related_name='products', verbose_name='دسته بندی محصول') - related_products = models.ManyToManyField('self', blank=True, verbose_name='محصولات مرتبط') - shop = models.ForeignKey('account.ShopModel', on_delete=models.CASCADE, related_name='products', verbose_name='فروشگاه', blank=True, null=True) - show_in_bot = models.BooleanField(default=False, verbose_name='نمایش در ربات') - bot_banner = models.TextField(null=True, blank=True, verbose_name='بنر ربات') + meta_description = models.CharField( + max_length=300, blank=True, null=True, help_text='این فیلد را حتما پر کنید', verbose_name='متا دیسکریپشن') + meta_keywords = models.CharField(max_length=300, blank=True, null=True, + help_text='این فیلد را حتما پر کنید', verbose_name='متا کیورد') + meta_rating = models.FloatField( + default=5, help_text='امتیاز محصول', verbose_name='متا ریتینگ') + created_at = models.DateTimeField( + auto_now_add=True, verbose_name='زمان ثبت محصول') + category = models.ForeignKey(SubCategoryModel, null=True, on_delete=models.SET_NULL, + related_name='products', verbose_name='دسته بندی محصول') + related_products = models.ManyToManyField( + 'self', blank=True, verbose_name='محصولات مرتبط') + shop = models.ForeignKey('account.ShopModel', on_delete=models.CASCADE, + related_name='products', verbose_name='فروشگاه', blank=True, null=True) + show_in_bot = models.BooleanField( + default=False, verbose_name='نمایش در ربات') + bot_banner = models.TextField( + null=True, blank=True, verbose_name='بنر ربات') def __str__(self): return self.name @@ -145,14 +177,13 @@ class ProductModel(models.Model): models.Index(fields=['name'], name='product_name_idx'), models.Index(fields=['created_at'], name='product_created_at_idx'), models.Index(fields=['show'], name='product_show_idx'), - models.Index(fields=['category', 'created_at'], name='product_category_created_idx'), - models.Index(fields=['category', 'name'], name='product_category_name_idx'), + models.Index(fields=['category', 'created_at'], + name='product_category_created_idx'), + models.Index(fields=['category', 'name'], + name='product_category_name_idx'), ] - - - class ProductDetailCategory(models.Model): title = models.CharField(max_length=40, verbose_name='عنوان') @@ -164,38 +195,43 @@ class ProductDetailCategory(models.Model): verbose_name_plural = 'دسته بندی های جزيیات' indexes = [ models.Index(fields=['title'], name='detail_category_title_idx'), - ] - - - - + ] class CommentModel(models.Model): - product = models.ForeignKey(ProductModel, on_delete=models.CASCADE, related_name='comments', verbose_name='محصول') + product = models.ForeignKey( + ProductModel, on_delete=models.CASCADE, related_name='comments', verbose_name='محصول') title = models.CharField(max_length=50) content = models.TextField(verbose_name='محتوای نظر') - user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='کاربر') - timestamp = models.DateTimeField(auto_now_add=True, verbose_name='زمان ثبت کامنت') + user = models.ForeignKey( + User, on_delete=models.CASCADE, verbose_name='کاربر') + timestamp = models.DateTimeField( + auto_now_add=True, verbose_name='زمان ثبت کامنت') status_types = ( ('reviewed_and_confirmed', 'بررسی و تایید شده'), ('reviewed_and_rejected', 'بررسی شده و رد شده'), ('not_reviwed', 'بررسی نشده'), ) - review_status = models.CharField(default='not_reviwed', verbose_name='نشان دادن کامنت', max_length=30, choices=status_types) + review_status = models.CharField( + default='not_reviwed', verbose_name='نشان دادن کامنت', max_length=30, choices=status_types) + class Meta: verbose_name = 'نظر' verbose_name_plural = 'نظرات' indexes = [ models.Index(fields=['product'], name='comment_product_idx'), - models.Index(fields=['review_status'], name='comment_review_status_idx'), - models.Index(fields=['product', 'review_status'], name='comment_product_status_idx'), + models.Index(fields=['review_status'], + name='comment_review_status_idx'), + models.Index(fields=['product', 'review_status'], + name='comment_product_status_idx'), models.Index(fields=['user'], name='comment_user_idx'), models.Index(fields=['timestamp'], name='comment_timestamp_idx'), ] + def __str__(self): return f"{self.user}-{self.content[:30]}" + class AttributeType(models.Model): name = models.CharField(verbose_name='نام نوع متغییر', max_length=100) @@ -206,9 +242,13 @@ class AttributeType(models.Model): verbose_name = 'نوع متغییر محصول' verbose_name_plural = 'نوع های متغییر محصول' + class AttributeValue(models.Model): - attribute_type = models.ForeignKey(AttributeType, on_delete=models.CASCADE, blank=True, null=True) - value = models.CharField(verbose_name='مقدار نوع اتربیوت', max_length=100, blank=True, null=True) + attribute_type = models.ForeignKey( + AttributeType, on_delete=models.CASCADE, blank=True, null=True) + value = models.CharField( + verbose_name='مقدار نوع اتربیوت', max_length=100, blank=True, null=True) + class Meta: unique_together = ('attribute_type', 'value') verbose_name = 'مقدار متغییر محصول' @@ -217,6 +257,7 @@ class AttributeValue(models.Model): def __str__(self): return f"{self.attribute_type}: {self.value}" + class ProductImageModel(models.Model): name = models.CharField(max_length=30, verbose_name='نام عکس') image = models.ImageField(upload_to='product_images/') @@ -230,26 +271,35 @@ class ProductImageModel(models.Model): class ProductDetailModel(models.Model): - name = models.CharField(max_length=50, verbose_name='نام جزيیات', help_text='این متن فقط برای راحتی در استفاده از پنل ادمین میباشد') - detail_category = models.ForeignKey(ProductDetailCategory, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات') + name = models.CharField(max_length=50, verbose_name='نام جزيیات', + help_text='این متن فقط برای راحتی در استفاده از پنل ادمین میباشد') + detail_category = models.ForeignKey( + ProductDetailCategory, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات') class Meta: verbose_name = 'جزیات محصول' verbose_name_plural = 'جزیات محصول ها' indexes = [ - models.Index(fields=['detail_category'], name='product_detail_category_idx'), + models.Index(fields=['detail_category'], + name='product_detail_category_idx'), ] + def __str__(self): return f'جزيیات محصول {self.detail_category.title} - {self.name}' class DetailModel(models.Model): title = models.CharField(max_length=50, verbose_name='عنوان') - detail_text1 = models.CharField(max_length=150 , verbose_name='متن جزیات ۱') - detail_text2 = models.CharField(max_length=150 , verbose_name='متن جزیات ۲', blank=True, null=True) - detail_text3 = models.CharField(max_length=150 , verbose_name='متن جزیات ۳', blank=True, null=True) - detail_text4 = models.CharField(max_length=150 , verbose_name='متن جزیات ۴', blank=True, null=True) - detail_model = models.ForeignKey(ProductDetailModel, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات', related_name='details') + detail_text1 = models.CharField(max_length=150, verbose_name='متن جزیات ۱') + detail_text2 = models.CharField( + max_length=150, verbose_name='متن جزیات ۲', blank=True, null=True) + detail_text3 = models.CharField( + max_length=150, verbose_name='متن جزیات ۳', blank=True, null=True) + detail_text4 = models.CharField( + max_length=150, verbose_name='متن جزیات ۴', blank=True, null=True) + detail_model = models.ForeignKey( + ProductDetailModel, on_delete=models.CASCADE, verbose_name='دسته بندی جزيات', related_name='details') + def __str__(self): return f'{self.title}' @@ -263,46 +313,70 @@ class DetailModel(models.Model): class ProductVariant(models.Model): - product = models.ForeignKey(ProductModel, on_delete=models.CASCADE, related_name='variants', verbose_name='محصول') - product_attributes = models.ManyToManyField(AttributeValue, verbose_name='ویژگی‌ها', related_name='variant') - in_stock = models.PositiveIntegerField(default=0, verbose_name='تعداد موجود') - price = models.PositiveIntegerField(verbose_name='قیمت محاسبه شده', blank=True, null=True) - input_price = models.PositiveIntegerField(default=0, verbose_name='قیمت ورودی') - min_price = models.PositiveIntegerField(verbose_name='قیمت کف', help_text='این قیمت برای کف قیمتی محصول در نظر گرفته میشود') + product = models.ForeignKey( + ProductModel, on_delete=models.CASCADE, related_name='variants', verbose_name='محصول') + product_attributes = models.ManyToManyField( + AttributeValue, verbose_name='ویژگی‌ها', related_name='variant') + in_stock = models.PositiveIntegerField( + default=0, verbose_name='تعداد موجود') + price = models.PositiveIntegerField( + verbose_name='قیمت محاسبه شده', blank=True, null=True) + input_price = models.PositiveIntegerField( + default=0, verbose_name='قیمت ورودی') + min_price = models.PositiveIntegerField( + verbose_name='قیمت کف', help_text='این قیمت برای کف قیمتی محصول در نظر گرفته میشود') + profit = models.BigIntegerField( + default=0, verbose_name='سود (تومان)', help_text='مقدار سود به ازای هر واحد به تومان') + special_discount_percent = models.SmallIntegerField( + default=0, verbose_name='درصد تخفیف ویژه', help_text='درصدی که از سود برای محاسبه تخفیف ویژه استفاده می‌شود') currency_type = ( ('dollor', 'دلار'), ('toman', 'تومان'), ('derham', 'درهم') ) - in_pack_items = models.ManyToManyField(InPackItems, blank=True, verbose_name='ایتم های داخل پک') + in_pack_items = models.ManyToManyField( + InPackItems, blank=True, verbose_name='ایتم های داخل پک') sell = models.IntegerField(default=0, verbose_name='فروش') - currency = models.CharField(verbose_name='نوع ارز', max_length=20, choices=currency_type) + currency = models.CharField( + verbose_name='نوع ارز', max_length=20, choices=currency_type) discount = models.SmallIntegerField(default=0, verbose_name='تخفیف') - color = models.CharField(verbose_name='رنگ', max_length=7, blank=True, null=True) + color = models.CharField( + verbose_name='رنگ', max_length=7, blank=True, null=True) images = models.ManyToManyField(ProductImageModel, verbose_name='عکس ها') - video = models.FileField(upload_to='product_videos/', blank=True, null=True, verbose_name='ویدیو') - details = models.ManyToManyField(ProductDetailModel, verbose_name='جزییات محصول', related_name='product') - slider_category = models.ForeignKey(ShowCaseSlider, verbose_name='دسته بندی پورسانتی', blank=True, null=True, on_delete=models.CASCADE) - created_at = models.DateTimeField(auto_now_add=True, verbose_name='زمان ثبت محصول') + video = models.FileField(upload_to='product_videos/', + blank=True, null=True, verbose_name='ویدیو') + details = models.ManyToManyField( + ProductDetailModel, verbose_name='جزییات محصول', related_name='product') + slider_category = models.ForeignKey( + ShowCaseSlider, verbose_name='دسته بندی پورسانتی', blank=True, null=True, on_delete=models.CASCADE) + created_at = models.DateTimeField( + auto_now_add=True, verbose_name='زمان ثبت محصول') class Meta: verbose_name = 'تنوع محصول' verbose_name_plural = 'تنوع‌های محصول' indexes = [ - models.Index(fields=['product', 'price', 'created_at'], name='idx_product_price_created'), - models.Index(fields=['in_stock', 'discount'], name='idx_stock_discount'), - models.Index(fields=['created_at'], name='idx_created'), + models.Index(fields=['product', 'price', 'created_at'], + name='idx_product_price_created'), + models.Index(fields=['in_stock', 'discount'], + name='idx_stock_discount'), + models.Index(fields=['created_at'], name='idx_created'), models.Index(fields=['price'], name='idx_price'), - models.Index(fields=['discount'], name='product_variant_discount_idx'), - models.Index(fields=['in_stock'], name='product_variant_in_stock_idx'), - models.Index(fields=['product'], name='product_variant_product_idx'), - models.Index(fields=['product', 'in_stock'], name='variant_product_stock_idx'), - models.Index(fields=['product', 'discount'], name='variant_product_discount_idx'), + models.Index(fields=['discount'], + name='product_variant_discount_idx'), + models.Index(fields=['in_stock'], + name='product_variant_in_stock_idx'), + models.Index(fields=['product'], + name='product_variant_product_idx'), + models.Index(fields=['product', 'in_stock'], + name='variant_product_stock_idx'), + models.Index(fields=['product', 'discount'], + name='variant_product_discount_idx'), ] def __str__(self): return f"{self.product.name} - {', '.join(str(attr) for attr in self.product_attributes.all())}" - + @property def price_before_discount(self): return self.price @@ -310,18 +384,28 @@ class ProductVariant(models.Model): @property def price_after_discount(self): return self.price - self.discount_amount - + @property def discount_amount(self): return self.price * (self.discount / 100) - + + @property + def special_discount_amount_per_unit(self): + """Calculate special discount amount per unit as profit * special_discount_percent / 100.""" + try: + return int(self.profit * (self.special_discount_percent / 100)) + except Exception: + return 0 + def set_or_update_price(self, dollor_price=None): if not dollor_price: - dollor_object, _ = DollorModel.objects.get_or_create(unique_filed='unique') + dollor_object, _ = DollorModel.objects.get_or_create( + unique_filed='unique') dollor_price = dollor_object.price if dollor_price is None: - raise ValidationError({"dollor_price": "The 'dollor_price' must be provided in the context for dollar pricing."}) + raise ValidationError( + {"dollor_price": "The 'dollor_price' must be provided in the context for dollar pricing."}) dollar_to_dirham = 0.27 diff --git a/frontend/components/cart/global/CartSummary.vue b/frontend/components/cart/global/CartSummary.vue index 4bf28d2..90ece21 100644 --- a/frontend/components/cart/global/CartSummary.vue +++ b/frontend/components/cart/global/CartSummary.vue @@ -2,9 +2,11 @@ // imports import useDeleteDiscountCode from "~/composables/api/orders/useDeleteDiscountCode"; +import useDeleteSpecialDiscountCode from "~/composables/api/orders/useDeleteSpecialDiscountCode"; import useGetCartOrders from "~/composables/api/orders/useGetCartOrders"; import usePayOrder from "~/composables/api/orders/usePayOrder"; import useSubmitDiscountCode from "~/composables/api/orders/useSubmitDiscountCode"; +import useSubmitSpecialDiscountCode from "~/composables/api/orders/useSubmitSpecialDiscountCode"; import { useToast } from "~/composables/global/useToast"; import { QUERY_KEYS } from "~/constants"; @@ -19,11 +21,16 @@ const { addToast } = useToast(); const { data: cart, isLoading: cartIsLoading } = useGetCartOrders(); const discountCode = ref(cart.value?.discount_code?.code || ""); +const specialDiscountCode = ref(cart.value?.special_discount_code?.code || ""); const { mutateAsync: submitDiscountCode, isPending: submitDiscountCodeIsPending } = useSubmitDiscountCode(); const { mutateAsync: deleteDiscountCode, isPending: deleteDiscountCodeIsPending } = useDeleteDiscountCode(); +const { mutateAsync: submitSpecialDiscountCode, isPending: submitSpecialDiscountCodeIsPending } = useSubmitSpecialDiscountCode(); + +const { mutateAsync: deleteSpecialDiscountCode, isPending: deleteSpecialDiscountCodeIsPending } = useDeleteSpecialDiscountCode(); + const { mutateAsync: pay, isPending: paymentIsPending } = usePayOrder(); // computed @@ -32,6 +39,8 @@ const nextPage = computed(() => route.meta.nextPage as { name: string; label: st const hasSubmittedDiscountCode = computed(() => !!cart.value?.discount_code); +const hasSubmittedSpecialDiscountCode = computed(() => !!cart.value?.special_discount_code); + // methods const handleSubmitDiscountCode = () => { @@ -72,6 +81,44 @@ const handleDeleteDiscountCode = () => { }); }; +const handleSubmitSpecialDiscountCode = () => { + submitSpecialDiscountCode( + { code: specialDiscountCode.value }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.cart] }); + }, + onError: () => { + addToast({ + message: "خطایی در ثبت کد تخفیف ویژه رخ داد", + options: { + status: "error", + }, + }); + specialDiscountCode.value = ""; + }, + } + ); +}; + +const handleDeleteSpecialDiscountCode = () => { + deleteSpecialDiscountCode(undefined, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.cart] }); + specialDiscountCode.value = ""; + }, + onError: () => { + addToast({ + message: "خطایی در حذف کد تخفیف ویژه رخ داد", + options: { + status: "error", + }, + }); + specialDiscountCode.value = ""; + }, + }); +}; + const handlePayment = () => { pay( { @@ -95,6 +142,30 @@ const handlePayment = () => { } ); }; + +// watch + +watch( + () => cart.value?.discount_code, + (newCode) => { + if (newCode) { + discountCode.value = newCode.code; + } else { + discountCode.value = ""; + } + } +); + +watch( + () => cart.value?.special_discount_code, + (newCode) => { + if (newCode) { + specialDiscountCode.value = newCode.code; + } else { + specialDiscountCode.value = ""; + } + } +);