diff --git a/backend/order/admin.py b/backend/order/admin.py index f7527bf..a930e5c 100644 --- a/backend/order/admin.py +++ b/backend/order/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin, messages from .models import * from unfold.admin import TabularInline, StackedInline - +from django.db.models import Q from import_export.admin import ImportExportModelAdmin from unfold.contrib.import_export.forms import ExportForm, ImportForm, SelectableFieldsExportForm from unfold.contrib.forms.widgets import ArrayWidget, WysiwygWidget @@ -47,11 +47,11 @@ class BankRecordInline(StackedInline): class OrderAdmin(ModelAdmin, ImportExportModelAdmin): import_form_class = ImportForm export_form_class = ExportForm - search_fields = ['order_id', 'user__phone', 'user__first_name', 'user__last_name', 'user__email'] + search_fields = ['user__phone', 'user__first_name', 'user__last_name', 'user__email'] list_filter = ['is_paid', 'status'] actions_list = ['redirect_to_learn', 'udpate_bank_status'] list_display = ['order_id', 'user', 'is_paid', 'status', 'discount_code', 'address',] - readonly_fields = ('created_at', 'order_id', 'tax', 'final_price', 'cart_total', 'discount', 'discount_code', 'user', 'address', 'is_paid') + readonly_fields = ('created_at', 'tax', 'final_price', 'cart_total', 'discount_amount', 'discount_code', 'user', 'address', 'is_paid') compressed_fields = True warn_unsaved_form = True # exclude = ('bank_records',) @@ -61,19 +61,21 @@ class OrderAdmin(ModelAdmin, ImportExportModelAdmin): } } inlines = [OrderItemModelInline, BankRecordInline] - # def bank_links(self, obj): - # banks = obj.bank_records.all() + def order_id(self, obj): + return f"سفارش {obj.pk + 1000}" + order_id.short_description = "شماره سفارش" - # if not banks.exists(): - # return "-" - # return format_html_join( - # "", - # '{}', - # [(bank.id, bank.tracking_code) for bank in banks] - # ) or "-" - # bank_links.short_description = "Bank Records" + def get_search_results(self, request, queryset, search_term): + queryset, use_distinct = super().get_search_results(request, queryset, search_term) + + + if search_term.isdigit(): + order_id_search = int(search_term) - 1000 + queryset |= self.model.objects.filter(Q(pk=order_id_search)) + + return queryset, use_distinct @action(description='اپدیت وضعیت رکورد های بانکی') def udpate_bank_status(self, request): diff --git a/backend/order/migrations/0026_alter_orderitemmodel_discount_and_more.py b/backend/order/migrations/0026_alter_orderitemmodel_discount_and_more.py new file mode 100644 index 0000000..c50f7ea --- /dev/null +++ b/backend/order/migrations/0026_alter_orderitemmodel_discount_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2025-03-29 13:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0025_alter_ordermodel_order_id'), + ] + + operations = [ + migrations.AlterField( + model_name='orderitemmodel', + name='discount', + field=models.SmallIntegerField(verbose_name='درصد تخفیف'), + ), + migrations.AlterField( + model_name='orderitemmodel', + name='price', + field=models.PositiveIntegerField(verbose_name='قیمت'), + ), + ] diff --git a/backend/order/migrations/0027_rename_discount_orderitemmodel_discount_percent_and_more.py b/backend/order/migrations/0027_rename_discount_orderitemmodel_discount_percent_and_more.py new file mode 100644 index 0000000..2a3c6d6 --- /dev/null +++ b/backend/order/migrations/0027_rename_discount_orderitemmodel_discount_percent_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.1.2 on 2025-03-30 15:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0026_alter_orderitemmodel_discount_and_more'), + ] + + operations = [ + migrations.RenameField( + model_name='orderitemmodel', + old_name='discount', + new_name='discount_percent', + ), + migrations.RenameField( + model_name='ordermodel', + old_name='discount', + new_name='discount_amount', + ), + migrations.RemoveField( + model_name='ordermodel', + name='order_id', + ), + migrations.AlterField( + model_name='ordermodel', + name='status', + field=models.CharField(choices=[('CART', 'در سبد خرید'), ('ADMIN_PENDING', 'در انتظار تایید'), ('PENDING', 'درحال پردازش'), ('POSTED', 'ارسال شده'), ('RECEIVED', 'تحویل شده'), ('CANCELED', 'لغو شده'), ('REFUNDED', 'مرجوع شده')], default='CART', max_length=20, verbose_name='وضعیت سفارش'), + ), + ] diff --git a/backend/order/migrations/0028_alter_orderitemmodel_price.py b/backend/order/migrations/0028_alter_orderitemmodel_price.py new file mode 100644 index 0000000..8a97b6c --- /dev/null +++ b/backend/order/migrations/0028_alter_orderitemmodel_price.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-03-30 15:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0027_rename_discount_orderitemmodel_discount_percent_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='orderitemmodel', + name='price', + field=models.BigIntegerField(verbose_name='قیمت'), + ), + ] diff --git a/backend/order/migrations/0029_alter_ordermodel_discount_amount.py b/backend/order/migrations/0029_alter_ordermodel_discount_amount.py new file mode 100644 index 0000000..e82ad50 --- /dev/null +++ b/backend/order/migrations/0029_alter_ordermodel_discount_amount.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2025-03-30 16:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0028_alter_orderitemmodel_price'), + ] + + operations = [ + migrations.AlterField( + model_name='ordermodel', + name='discount_amount', + field=models.BigIntegerField(blank=True, null=True, verbose_name='مقدار کد تخفیف'), + ), + ] diff --git a/backend/order/models.py b/backend/order/models.py index 675bd07..3547194 100644 --- a/backend/order/models.py +++ b/backend/order/models.py @@ -1,9 +1,10 @@ -from django.db import models +from django.db import models, transaction from account.models import User, UserAddressModel, PushSubscription from product.models import ProductModel, ProductVariant, ProductImageModel from django.utils import timezone from django_jalali.db import models as jmodels - +from django.core.exceptions import ValidationError +from django.conf import settings class DiscountCode(models.Model): code = models.CharField(max_length=50, verbose_name='کد تخفیف') @@ -44,20 +45,19 @@ class OrderModel(models.Model): ('CANCELED', 'لغو شده'), ('REFUNDED', 'مرجوع شده'), ] - order_id = models.PositiveIntegerField(unique=True, null=True, blank=True, verbose_name='شماره سفارش') user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='orders', verbose_name='کاربر') address = models.ForeignKey(UserAddressModel, on_delete=models.SET_NULL, related_name='orders', null=True, verbose_name='ادرس') created_at = jmodels.jDateField(blank=True, null=True, verbose_name="تاریخ ثبت سفارش") is_paid = models.BooleanField(default=False, verbose_name="وضعیت پرداخت") discount_code = models.ForeignKey(DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف") - status = models.CharField(max_length=20, choices=STATUS_CHOICES, verbose_name="وضعیت سفارش") - discount = models.BigIntegerField(null=True, blank=True, verbose_name='کل تخقیف') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='CART', verbose_name="وضعیت سفارش") + discount_amount = models.BigIntegerField(null=True, blank=True, verbose_name='مقدار کد تخفیف') tax = models.BigIntegerField(null=True, blank=True, verbose_name='مالیات') final_price = models.BigIntegerField(null=True, blank=True, verbose_name='قیمت نهایی') cart_total = models.BigIntegerField(null=True, blank=True, verbose_name='کل سبد خرید') def __str__(self): - return f'سفارش: {self.id + 1000}' + return f'سفارش: {self.pk + 1000}' class Meta: verbose_name = 'سفارش' @@ -65,58 +65,68 @@ class OrderModel(models.Model): - def save(self, *args, **kwargs): - # genrate order id - if not self.pk: - last_instance = self.__class__.objects.order_by("pk").last() - self.order_id = (last_instance.pk + 1001) if last_instance else 1001 - - super().save(*args, **kwargs) + def _cal_discount_amount(self, cart_total): + discount_percent = self.discount_code.percent if self.discount_code else 0 + return int(cart_total * discount_percent / 100) - - - def cal_discount(self): - # total_with_item_discount = sum(item.total_with_discount() for item in self.items.all()) - # discount_percent = self.discount_code.percent - # return total_with_item_discount * ((100 - discount_percent) / 100) - pass - - - def cal_tax(self): - return self.total_without_tax() * 0.2 + def _cal_tax(self, cart_total, discount_amount): + tax_rate = getattr(settings, 'DEFAULT_TAX_RATE', 20) + return int((cart_total - discount_amount) * tax_rate / 100) + def _cal_cart_total(self): + from django.db.models import Sum, F, FloatField + return self.items.aggregate( + total=Sum(F('price') * (1 - F('discount_percent')/100) * F('quantity'), + output_field=FloatField() + )).get('total') or 0 - def cal_total(self): - pass - return self.total_with_discount() + self.tax() - - - def cal_final_price(self): - pass - - + def _cal_final_price(self, cart_total, discount_amount, tax): + return cart_total - discount_amount + tax + def update_order(self): + if self.status == 'CART': + cart_total = self._cal_cart_total() + discount_amount = self._cal_discount_amount(cart_total) + self.discount_amount = discount_amount + self.cart_total = cart_total + tax = self._cal_tax(cart_total, discount_amount) + self.tax = tax + self.final_price = self._cal_final_price(cart_total, discount_amount, tax) + self.save() + else: + pass class OrderItemModel(models.Model): order = models.ForeignKey(OrderModel, on_delete=models.CASCADE, related_name='items', verbose_name='سفارش') quantity = models.PositiveSmallIntegerField(verbose_name="تعداد") - price = models.PositiveIntegerField(verbose_name='قیمت', default=0) + price = models.BigIntegerField(verbose_name='قیمت') product = models.ForeignKey(ProductVariant, on_delete=models.PROTECT, verbose_name="محصول") - discount = models.SmallIntegerField(default=0, verbose_name='تخفیف') + discount_percent = models.SmallIntegerField(verbose_name='درصد تخفیف') class Meta: verbose_name = 'ایتم سبد خرید' verbose_name_plural = 'ایتم های سبد خرید' - # def total(self): - # return self.quantity * self.product.price - # def total_with_discount(self): - # return self.quantity * self.product.get_toman_price_after_discount() - - def update_fields(self): - pass + def set_or_update_fields(self): + self.price = self.product.price + self.discount_percent = self.product.discount def __str__(self): return f'({self.product}) - ({self.order.user})' + def save(self, *args, **kwargs): + self.clean() + self.set_or_update_fields() + super().save(*args, **kwargs) + self.order.update_order() + + def delete(self, *args, **kwargs): + self.clean() + order = self.order + super().delete(*args, **kwargs) + order.update_order() + + def clean(self): + if self.pk and self.order.status != "CART": + raise ValidationError("ایتم ها فقط در حالت سبد خرید قابل ادیت هستند") \ No newline at end of file diff --git a/backend/order/serializers.py b/backend/order/serializers.py index a50d23d..3c9a0f8 100644 --- a/backend/order/serializers.py +++ b/backend/order/serializers.py @@ -53,7 +53,7 @@ class OrderItemSerailzier(serializers.ModelSerializer): class Meta: model = OrderItemModel exclude = ('order',) - read_only_fields = ('order', 'product',) + read_only_fields = ('order', 'product', 'discount_percent') def get_product(self, obj): return ProductVariantSerialzier(instance=obj.product, context={'request': self.context.get('request')}).data @@ -79,9 +79,10 @@ class CartSerializer(serializers.ModelSerializer): tax = serializers.SerializerMethodField() final_price = serializers.SerializerMethodField() discount_code = serializers.SerializerMethodField() + address = UserAddressSerializer() class Meta: model = OrderModel - fields = [ 'discount_code', 'items', 'cart_total', 'tax', 'final_price'] + fields = [ 'discount_code', 'items', 'cart_total', 'tax', 'final_price', 'address'] def get_discount_code(self, obj): @@ -109,6 +110,7 @@ class OrderListSerializer(serializers.ModelSerializer): 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'] @@ -119,6 +121,8 @@ class OrderListSerializer(serializers.ModelSerializer): def get_count(self, obj): return obj.items.all().count() + 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) @@ -138,7 +142,7 @@ class OrderGetSerializer(serializers.ModelSerializer): 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'] + fields = ['created_at', 'status', "images", "count", "id", 'final_price', 'order_id', 'verbose_status', 'address', 'items', 'tax' , 'cart_total', 'discount_code', 'discount_amount'] def get_verbose_status(self, obj): return obj.get_status_display() diff --git a/backend/order/signals.py b/backend/order/signals.py index 793f46e..64555d0 100644 --- a/backend/order/signals.py +++ b/backend/order/signals.py @@ -1,7 +1,7 @@ from django.db.models.signals import pre_save from django.dispatch import receiver from .models import OrderModel -from account.models import PushSubscription +from account.models import PushSubscription, UserAddressModel import ghasedak_sms from .tasks import send_change_status_notif, send_change_status_sms @@ -13,8 +13,8 @@ def order_status_changed(sender, instance, **kwargs): if previous.status != instance.status: new_status = instance.get_status_display() - send_change_status_notif.delay(instance.pk, new_status) - send_change_status_sms.delay(instance.pk, new_status) + # send_change_status_notif.delay(instance.pk, new_status) + # send_change_status_sms.delay(instance.pk, new_status) if previous.status == 'CART' and instance.status == 'ADMIN_PENDING': # update_cart_price_fields() @@ -23,11 +23,17 @@ def order_status_changed(sender, instance, **kwargs): pass +@receiver(pre_save, sender=OrderModel) +def set_default_address(sender, instance, **kwargs): + if instance.address is None and instance.user: + default_address = UserAddressModel.objects.filter(user=instance.user, is_main=True).first() + if default_address: + instance.address = default_address -def update_cart_price_fields(order): - pass +# def update_cart_price_fields(order): +# pass def update_sell_data(order): pass diff --git a/backend/order/views.py b/backend/order/views.py index 2308d81..7fa4906 100644 --- a/backend/order/views.py +++ b/backend/order/views.py @@ -297,7 +297,7 @@ class BankCallbackSerializer(serializers.ModelSerializer): PaymentStatus.REDIRECT_TO_BANK, PaymentStatus.RETURN_FROM_BANK, }: - return "waiting" + return "pending" elif obj.status in { PaymentStatus.CANCEL_BY_USER, PaymentStatus.EXPIRE_GATEWAY_TOKEN, diff --git a/frontend/components/global/Footer.vue b/frontend/components/global/Footer.vue index a02b380..42551b2 100644 --- a/frontend/components/global/Footer.vue +++ b/frontend/components/global/Footer.vue @@ -4,6 +4,9 @@ src="/img/footer-bg.jpg" alt="" class="absolute z-10 object-cover opacity-45" + :style="{ + mask: 'linear-gradient(to bottom, black 0%, rgba(0,0,0,0) 100%', + }" />
diff --git a/frontend/components/global/LoadingOverlay.vue b/frontend/components/global/LoadingOverlay.vue index 0421860..57494c6 100644 --- a/frontend/components/global/LoadingOverlay.vue +++ b/frontend/components/global/LoadingOverlay.vue @@ -5,6 +5,7 @@ const { $gsap: gsap } = useNuxtApp(); const isSiteLoadingDisabled = useCookie("is-site-loading-disabled", { default: () => false, + expires: new Date(Date.now() + 15 * 60 * 1000), }); const shouldRenderLoadingOverlay = ref(true); @@ -27,8 +28,8 @@ const progressStyle = computed(() => { // methods const onAssetLoaded = () => { - criticalLoad.value = false; clearInterval(progressInterval.value!); + criticalLoad.value = false; assetLoadingProgress.value = 100; isAssetLoaded.value = true; }; @@ -55,30 +56,31 @@ watch([assetLoadingProgress, criticalLoad], ([assetLoadingProgress, criticalLoad // lifecycle onMounted(() => { - isWindowScrollLocked.value = true; - if (!isSiteLoadingDisabled.value) { + isWindowScrollLocked.value = true; + + const heymlzLoadingAnimation = document.querySelector("#heymlz-loading-animation") as HTMLVideoElement; + + if (heymlzLoadingAnimation?.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) { + onAssetLoaded(); + } + + progressInterval.value = setInterval(() => { + assetLoadingProgress.value += Math.random() * 10; + }, 250); + + gsap.to("#loading-overlay", { + opacity: 1, + }); + } else { + shouldRenderLoadingOverlay.value = false; } - - const heymlzLoadingAnimation = document.querySelector("#heymlz-loading-animation") as HTMLVideoElement; - - if (heymlzLoadingAnimation?.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) { - onAssetLoaded(); - } - - progressInterval.value = setInterval(() => { - assetLoadingProgress.value += Math.random() * 10; - }, 250); - - gsap.to("#loading-overlay", { - opacity: 1, - }); });