Enhance discount logic and shop configuration models
Refactored discount application logic in OrderItem to support multiple coupons with configurable multiplication and addition rules from ShopConfiguration. Updated DiscountCode model to use PositiveIntegerField for percent and improved validation. Extended ShopConfiguration with new fields for contact, social media, and coupon settings. Ensured ShopConfiguration singleton creation at app startup.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
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 decimal import Decimal
|
||||||
|
|
||||||
from configuration.models import ShopConfiguration
|
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")
|
carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0")
|
||||||
|
|
||||||
if self.discount.exists():
|
if self.discount.exists():
|
||||||
for discount in self.discount.all():
|
discounts = list(self.discount.all())
|
||||||
total = Decimal('0.0')
|
|
||||||
|
|
||||||
# getting all prices from order items (with discount applied if valid)
|
total = Decimal('0.0')
|
||||||
for item in self.items.all():
|
|
||||||
total = total + item.get_total_price(discount)
|
# 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
|
||||||
|
|
||||||
return total + carrier_price
|
|
||||||
else:
|
else:
|
||||||
total = Decimal('0.0')
|
total = Decimal('0.0')
|
||||||
# getting all prices from order items (without discount)
|
# getting all prices from order items (without discount)
|
||||||
@@ -255,8 +257,7 @@ class DiscountCode(models.Model):
|
|||||||
description = models.CharField(max_length=255, blank=True)
|
description = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
# sleva v procentech (0–100)
|
# 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
|
# nebo fixní částka
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK")
|
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)
|
valid_to = models.DateTimeField(null=True, blank=True)
|
||||||
active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
#max počet použití
|
||||||
usage_limit = models.PositiveIntegerField(null=True, blank=True)
|
usage_limit = models.PositiveIntegerField(null=True, blank=True)
|
||||||
used_count = models.PositiveIntegerField(default=0)
|
used_count = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
|
|
||||||
specific_products = models.ManyToManyField(
|
specific_products = models.ManyToManyField(
|
||||||
Product, blank=True, related_name="discount_codes"
|
Product, blank=True, related_name="discount_codes"
|
||||||
)
|
)
|
||||||
@@ -278,11 +281,12 @@ class DiscountCode(models.Model):
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
if not self.active:
|
if not self.active:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.valid_to and self.valid_to < now:
|
if self.valid_to and self.valid_to < now:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self.usage_limit and self.used_count >= self.usage_limit:
|
if self.usage_limit and self.used_count >= self.usage_limit:
|
||||||
return False
|
return False
|
||||||
return True
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
|
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)
|
product = models.ForeignKey("products.Product", on_delete=models.PROTECT)
|
||||||
quantity = models.PositiveIntegerField(default=1)
|
quantity = models.PositiveIntegerField(default=1)
|
||||||
|
|
||||||
def get_total_price(self, discount_object:DiscountCode = None):
|
def get_total_price(self, discounts: list[DiscountCode] = None):
|
||||||
#FIXME: přidat logiku pro slevové kódy
|
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
|
||||||
"""Calculate total price for this item, applying discount if valid."""
|
|
||||||
|
|
||||||
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 not discounts:
|
||||||
if discount_object.percent:
|
return base_price
|
||||||
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:
|
config = ShopConfiguration.get_solo()
|
||||||
raise ValueError("Discount code must have either percent or amount defined.")
|
|
||||||
|
|
||||||
elif not discount_object:
|
#seznám slev
|
||||||
return self.quantity * self.product.price
|
applicable_percent_discounts: list[int] = []
|
||||||
|
applicable_amount_discounts: list[Decimal] = []
|
||||||
|
|
||||||
else:
|
#procházení kupónů a určení, které se aplikují
|
||||||
raise ValueError("Invalid discount code.")
|
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):
|
def __str__(self):
|
||||||
return f"{self.product.name} x{self.quantity}"
|
return f"{self.product.name} x{self.quantity}"
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
from .models import ShopConfiguration
|
||||||
|
|
||||||
class ConfigurationConfig(AppConfig):
|
class ConfigurationConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'configuration'
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,30 @@ from django.db import models
|
|||||||
class ShopConfiguration(models.Model):
|
class ShopConfiguration(models.Model):
|
||||||
name = models.CharField(max_length=100, default="Shop name", unique=True)
|
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_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")
|
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)
|
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):
|
class CURRENCY(models.TextChoices):
|
||||||
CZK = "CZK", "Czech Koruna"
|
CZK = "CZK", "Czech Koruna"
|
||||||
EUR = "EUR", "Euro"
|
EUR = "EUR", "Euro"
|
||||||
|
|||||||
Reference in New Issue
Block a user