diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 0ad855c..0c68cef 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -1,7 +1,7 @@ from django.db import models from django.conf import settings from django.utils import timezone -#from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from decimal import Decimal from configuration.models import ShopConfiguration @@ -140,14 +140,16 @@ class Order(models.Model): carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0") if self.discount.exists(): - for discount in self.discount.all(): - total = Decimal('0.0') + discounts = list(self.discount.all()) - # getting all prices from order items (with discount applied if valid) - for item in self.items.all(): - total = total + item.get_total_price(discount) + total = Decimal('0.0') - return total + carrier_price + # getting all prices from order items (with discount applied if valid) + for item in self.items.all(): + total = total + item.get_total_price(discounts) + + return total + carrier_price + else: total = Decimal('0.0') # getting all prices from order items (without discount) @@ -255,8 +257,7 @@ class DiscountCode(models.Model): description = models.CharField(max_length=255, blank=True) # sleva v procentech (0–100) - percent = models.DecimalField(max_digits=5, decimal_places=2, help_text="Např. 10.00 = 10% sleva") - + percent = models.PositiveIntegerField(max_value=100, help_text="Procento sleva 0-100", null=True, blank=True) # nebo fixní částka amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK") @@ -264,9 +265,11 @@ class DiscountCode(models.Model): valid_to = models.DateTimeField(null=True, blank=True) active = models.BooleanField(default=True) + #max počet použití usage_limit = models.PositiveIntegerField(null=True, blank=True) used_count = models.PositiveIntegerField(default=0) + specific_products = models.ManyToManyField( Product, blank=True, related_name="discount_codes" ) @@ -278,11 +281,12 @@ class DiscountCode(models.Model): now = timezone.now() if not self.active: return False + if self.valid_to and self.valid_to < now: return False + if self.usage_limit and self.used_count >= self.usage_limit: return False - return True def __str__(self): return f"{self.code} ({self.percent}% or {self.amount} CZK)" @@ -295,27 +299,84 @@ class OrderItem(models.Model): product = models.ForeignKey("products.Product", on_delete=models.PROTECT) quantity = models.PositiveIntegerField(default=1) - def get_total_price(self, discount_object:DiscountCode = None): - #FIXME: přidat logiku pro slevové kódy - """Calculate total price for this item, applying discount if valid.""" + def get_total_price(self, discounts: list[DiscountCode] = None): + """Vrátí celkovou cenu položky po aplikaci relevantních kupónů. - if discount_object and discount_object.is_valid(): + Logika dle ShopConfiguration: + - multiplying_coupons=True: procentuální slevy se násobí (sekvenčně) + P * (1 - p1) -> výsledné * (1 - p2) ... + jinak se použije pouze nejlepší (nejvyšší procento). + - addition_of_coupons_amount=True: fixní částky (amount) se sčítají, + jinak se použije pouze nejvyšší částka. + - Kombinace: nejprve procentuální část, poté odečtení fixní částky. + - Sleva se nikdy nesmí dostat pod 0. + """ + base_price = self.product.price * self.quantity - if (self.product in discount_object.specific_products.all() or self.product.category in discount_object.specific_categories.all()): - if discount_object.percent: - return (self.quantity * self.product.price) * (Decimal('1.0') - discount_object.percent / Decimal('100')) - - elif discount_object.amount: - return (self.quantity * self.product.price) - discount_object.amount - - else: - raise ValueError("Discount code must have either percent or amount defined.") - - elif not discount_object: - return self.quantity * self.product.price - - else: - raise ValueError("Invalid discount code.") + if not discounts: + return base_price + + + config = ShopConfiguration.get_solo() + + #seznám slev + applicable_percent_discounts: list[int] = [] + applicable_amount_discounts: list[Decimal] = [] + + #procházení kupónů a určení, které se aplikují + for discount in set(discounts): + if not discount: + continue + + if not discount.is_valid(): + raise ValueError("Invalid discount code.") + + #defaulting + applies = False + + # Určení, zda kupon platí pro produkt/kategorii + # prázdný produkt a kategorie = globální kupon + if discount.specific_products.exists() or discount.specific_categories.exists(): + if (self.product in discount.specific_products.all() or self.product.category in discount.specific_categories.all()): + applies = True + + else: + applies = True #global + + if not applies: + continue + + if discount.percent is not None: + applicable_percent_discounts.append(discount.percent) + elif discount.amount is not None: + applicable_amount_discounts.append(discount.amount) + + final_price = base_price + + # Procentuální slevy + if applicable_percent_discounts: + + if config.multiplying_coupons: + for pct in applicable_percent_discounts: + factor = (Decimal('1') - (Decimal(pct) / Decimal('100'))) + final_price = final_price * factor + else: + best_pct = max(applicable_percent_discounts) + factor = (Decimal('1') - (Decimal(best_pct) / Decimal('100'))) + final_price = final_price * factor + + # Fixní částky + if applicable_amount_discounts: + if config.addition_of_coupons_amount: + total_amount = sum(applicable_amount_discounts) + else: + total_amount = max(applicable_amount_discounts) + final_price = final_price - total_amount + + if final_price < Decimal('0'): + final_price = Decimal('0') + + return final_price.quantize(Decimal('0.01')) def __str__(self): return f"{self.product.name} x{self.quantity}" \ No newline at end of file diff --git a/backend/configuration/apps.py b/backend/configuration/apps.py index eab8aa0..d3265d0 100644 --- a/backend/configuration/apps.py +++ b/backend/configuration/apps.py @@ -1,6 +1,21 @@ from django.apps import AppConfig - +from django.db.utils import OperationalError, ProgrammingError +from .models import ShopConfiguration class ConfigurationConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'configuration' + + def ready(self): + """Ensure the ShopConfiguration singleton exists at startup. + + Wrapped in broad DB error handling so that commands like + makemigrations/migrate don't fail when the table does not yet exist. + """ + try: + ShopConfiguration.get_solo() + + except (OperationalError, ProgrammingError): + ShopConfiguration.objects.create() + + diff --git a/backend/configuration/models.py b/backend/configuration/models.py index c04e12b..61941fd 100644 --- a/backend/configuration/models.py +++ b/backend/configuration/models.py @@ -5,10 +5,30 @@ from django.db import models class ShopConfiguration(models.Model): name = models.CharField(max_length=100, default="Shop name", unique=True) + logo = models.ImageField(upload_to='shop_logos/', blank=True, null=True) + favicon = models.ImageField(upload_to='shop_favicons/', blank=True, null=True) + + contact_email = models.EmailField(max_length=254, blank=True, null=True) + contact_phone = models.CharField(max_length=20, blank=True, null=True) + contact_address = models.TextField(blank=True, null=True) + opening_hours = models.JSONField(blank=True, null=True) #FIXME: vytvoř tvar pro otvírací dobu + + #Social + facebook_url = models.URLField(max_length=200, blank=True, null=True) + instagram_url = models.URLField(max_length=200, blank=True, null=True) + youtube_url = models.URLField(max_length=200, blank=True, null=True) + tiktok_url = models.URLField(max_length=200, blank=True, null=True) + whatsapp_number = models.CharField(max_length=20, blank=True, null=True) + + #zasilkovna settings zasilkovna_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=50) zasilkovna_address_id = models.CharField(max_length=100, blank=True, null=True, help_text="ID výdejního místa Zásilkovny pro odesílání zásilek") free_shipping_over = models.DecimalField(max_digits=10, decimal_places=2, default=2000) - + + #coupon settings + multiplying_coupons = models.BooleanField(default=True, help_text="Násobení kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón") + addition_of_coupons_amount = models.BooleanField(default=False, help_text="Sčítání slevových kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón") + class CURRENCY(models.TextChoices): CZK = "CZK", "Czech Koruna" EUR = "EUR", "Euro"