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.
382 lines
13 KiB
Python
382 lines
13 KiB
Python
from django.db import models
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
from django.utils.translation import gettext_lazy as _
|
||
from decimal import Decimal
|
||
|
||
from configuration.models import ShopConfiguration
|
||
from thirdparty.zasilkovna.models import ZasilkovnaPacket
|
||
from thirdparty.stripe.models import StripePayment
|
||
|
||
#FIXME: přidat soft delete pro všchny modely !!!!
|
||
|
||
class Category(models.Model):
|
||
name = models.CharField(max_length=100)
|
||
|
||
#adresa kategorie např: /category/elektronika/mobily/
|
||
url = models.SlugField(unique=True)
|
||
|
||
#kategorie se můžou skládat pod sebe
|
||
parent = models.ForeignKey(
|
||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='subcategories'
|
||
)
|
||
|
||
description = models.TextField(blank=True)
|
||
|
||
#ikona
|
||
image = models.ImageField(upload_to='categories/', blank=True)
|
||
|
||
class Meta:
|
||
verbose_name_plural = "Categories"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
|
||
class Product(models.Model):
|
||
name = models.CharField(max_length=200)
|
||
description = models.TextField(blank=True)
|
||
|
||
code = models.CharField(max_length=100, unique=True, blank=True, null=True)
|
||
|
||
|
||
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT)
|
||
|
||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||
|
||
url = models.SlugField(unique=True)
|
||
|
||
stock = models.PositiveIntegerField(default=0)
|
||
is_active = models.BooleanField(default=True)
|
||
|
||
#limitka (volitelné)
|
||
limited_to = models.DateTimeField(null=True, blank=True)
|
||
|
||
default_carrier = models.ForeignKey(
|
||
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
@property
|
||
def available(self):
|
||
return self.is_active and self.stock > 0
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.price} {self.currency.upper()})"
|
||
|
||
#obrázek pro produkty
|
||
class ProductImage(models.Model):
|
||
product = models.ForeignKey(Product, related_name='images', on_delete=models.CASCADE)
|
||
|
||
image = models.ImageField(upload_to='products/')
|
||
|
||
alt_text = models.CharField(max_length=150, blank=True)
|
||
is_main = models.BooleanField(default=False)
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name} image"
|
||
|
||
|
||
|
||
# ------------------ OBJEDNÁVKY ------------------
|
||
|
||
class Order(models.Model):
|
||
class Status(models.TextChoices):
|
||
PENDING = "pending", _("Čeká na platbu")
|
||
PAID = "paid", _("Zaplaceno")
|
||
CANCELLED = "cancelled", _("Zrušeno")
|
||
SHIPPED = "shipped", _("Odesláno")
|
||
#COMPLETED = "completed", _("Dokončeno")
|
||
|
||
status = models.CharField(
|
||
max_length=20, choices=Status.choices, default=Status.PENDING
|
||
)
|
||
|
||
# Stored order grand total; recalculated on save
|
||
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||
currency = models.CharField(max_length=10, default="CZK")
|
||
|
||
# fakturační údaje (zkopírované z user profilu při objednávce) FIXME: rozhodnout se co dát do on_delete
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
|
||
)
|
||
|
||
first_name = models.CharField(max_length=100)
|
||
last_name = models.CharField(max_length=100)
|
||
email = models.EmailField()
|
||
phone = models.CharField(max_length=20, blank=True)
|
||
address = models.CharField(max_length=255)
|
||
city = models.CharField(max_length=100)
|
||
postal_code = models.CharField(max_length=20)
|
||
country = models.CharField(max_length=100, default="Czech Republic")
|
||
|
||
note = models.TextField(blank=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
carrier = models.OneToOneField(
|
||
"Carrier",
|
||
on_delete=models.CASCADE,
|
||
related_name="orders",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
payment = models.OneToOneField(
|
||
"Payment",
|
||
on_delete=models.CASCADE,
|
||
related_name="orders",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
|
||
|
||
def calculate_total_price(self):
|
||
carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0")
|
||
|
||
if self.discount.exists():
|
||
discounts = list(self.discount.all())
|
||
|
||
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
|
||
|
||
else:
|
||
total = Decimal('0.0')
|
||
# getting all prices from order items (without discount)
|
||
|
||
for item in self.items.all():
|
||
total = total + (item.product.price * item.quantity)
|
||
|
||
return total + carrier_price
|
||
|
||
def import_data_from_user(self):
|
||
"""Import user data into order for billing purposes."""
|
||
self.first_name = self.user.first_name
|
||
self.last_name = self.user.last_name
|
||
self.email = self.user.email
|
||
self.phone = self.user.phone
|
||
self.address = f"{self.user.street} {self.user.street_number}"
|
||
self.city = self.user.city
|
||
self.postal_code = self.user.postal_code
|
||
self.country = self.user.country
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Keep total_price always in sync with items and discount
|
||
self.total_price = self.calculate_total_price()
|
||
|
||
if self.user and self.pk is None:
|
||
self.import_data_from_user()
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
|
||
class Carrier(models.Model):
|
||
class SHIPPING(models.TextChoices):
|
||
ZASILKOVNA = "packeta", "Zásilkovna"
|
||
STORE = "store", "Osobní odběr"
|
||
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
|
||
|
||
# prodejce to přidá později
|
||
zasilkovna = models.ForeignKey(
|
||
ZasilkovnaPacket, on_delete=models.DO_NOTHING, null=True, blank=True, related_name="carriers"
|
||
)
|
||
|
||
weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg")
|
||
|
||
returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení")
|
||
|
||
def save(self, *args, **kwargs):
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def get_price(self):
|
||
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||
return ShopConfiguration.get_solo().zasilkovna_shipping_price
|
||
else:
|
||
return Decimal('0.0')
|
||
|
||
|
||
#tohle bude vyvoláno pomocí admina přes api!!!
|
||
def start_ordering_shipping(self):
|
||
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||
#už při vytvoření se volá na api Zásilkovny
|
||
self.zasilkovna = ZasilkovnaPacket.objects.create()
|
||
|
||
#... další logika pro jiné způsoby dopravy
|
||
#TODO: přidat notifikace uživateli, jak pro zásilkovnu, tak pro vyzvednutí v obchodě!
|
||
|
||
def returning_shipping(self):
|
||
self.returning = True
|
||
|
||
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||
#volá se na api Zásilkovny
|
||
self.zasilkovna.returning_packet()
|
||
|
||
|
||
# ------------------ PLATEBNÍ MODELY ------------------
|
||
|
||
class Payment(models.Model):
|
||
class PAYMENT(models.TextChoices):
|
||
SHOP = "shop", "Platba v obchodě"
|
||
STRIPE = "stripe", "Bankovní převod"
|
||
CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka"
|
||
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
|
||
|
||
#active = models.BooleanField(default=True)
|
||
|
||
#FIXME: potvrdit že logika platby funguje správně
|
||
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
|
||
stripe = models.OneToOneField(
|
||
StripePayment, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
order = Order.objects.get(payment=self)
|
||
|
||
if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE:
|
||
raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.")
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
# ------------------ SLEVOVÉ KÓDY ------------------
|
||
|
||
class DiscountCode(models.Model):
|
||
code = models.CharField(max_length=50, unique=True)
|
||
description = models.CharField(max_length=255, blank=True)
|
||
|
||
# sleva v procentech (0–100)
|
||
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")
|
||
|
||
valid_from = models.DateTimeField(default=timezone.now)
|
||
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"
|
||
)
|
||
specific_categories = models.ManyToManyField(
|
||
Category, blank=True, related_name="discount_codes"
|
||
)
|
||
|
||
def is_valid(self):
|
||
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
|
||
|
||
def __str__(self):
|
||
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
|
||
|
||
|
||
# ------------------ OBJEDNANÉ POLOŽKY ------------------
|
||
|
||
class OrderItem(models.Model):
|
||
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
|
||
product = models.ForeignKey("products.Product", on_delete=models.PROTECT)
|
||
quantity = models.PositiveIntegerField(default=1)
|
||
|
||
def get_total_price(self, discounts: list[DiscountCode] = None):
|
||
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
|
||
|
||
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 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}" |