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:
David Bruno Vontor
2025-11-14 15:18:08 +01:00
parent f14c09bf7a
commit 7a715efeda
3 changed files with 127 additions and 31 deletions

View File

@@ -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')
# 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:
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 (0100)
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'))
if not discounts:
return base_price
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.")
config = ShopConfiguration.get_solo()
elif not discount_object:
return self.quantity * self.product.price
#seznám slev
applicable_percent_discounts: list[int] = []
applicable_amount_discounts: list[Decimal] = []
else:
raise ValueError("Invalid discount code.")
#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}"

View File

@@ -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()

View File

@@ -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"