from django.db import models from django.conf import settings from django.utils import timezone from django.core.exceptions import ValidationError from decimal import Decimal from django.template.loader import render_to_string from django.core.files.base import ContentFile from django.core.validators import MaxValueValidator, MinValueValidator from weasyprint import HTML import os from backend.commerce.tasks import notify_order_sended from configuration.models import ShopConfiguration from thirdparty.zasilkovna.models import ZasilkovnaPacket from thirdparty.stripe.models import StripeModel #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) variants = models.ManyToManyField( "self", symmetrical=True, blank=True, related_name="variant_of", help_text=( "Symetrické varianty produktu: pokud přidáte variantu A → B, " "Django automaticky přidá i variantu B → A. " "Všechny varianty jsou rovnocenné a zobrazí se vzájemně." ), ) 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) #časový limit (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): CREATED = "created", "Vytvořeno" CANCELLED = "cancelled", "Zrušeno" COMPLETED = "completed", "Dokončeno" REFUNDING = "refunding", "Vrácení v procesu" REFUNDED = "refunded", "Vráceno" status = models.CharField( max_length=20, choices=Status.choices, null=True, blank=True, default=Status.CREATED ) # 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) 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 ) invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", 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) class STATE(models.TextChoices): PREPARING = "ordered", "Objednávka se připravuje" SHIPPED = "shipped", "Odesláno" DELIVERED = "delivered", "Doručeno" READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí" #RETURNING = "returning", "Vracení objednávky" state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING) # prodejce to přidá později zasilkovna = models.ManyToManyField( ZasilkovnaPacket, 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živatel může objednat více zásilek pokud potřeba self.zasilkovna.add(ZasilkovnaPacket.objects.create()) self.returning = False self.save() elif self.shipping_method == self.SHIPPING.STORE: self.state = self.STATE.READY_TO_PICKUP self.save() else: raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.") notify_order_sended.delay(order=self.orders.first(), user=self.orders.first().user) #... další logika pro jiné způsoby dopravy #TODO: přidat notifikace uživateli, jak pro zásilkovnu, tak pro vyzvednutí v obchodě! def ready_to_pickup(self): if self.shipping_method == self.SHIPPING.STORE: self.state = self.STATE.READY_TO_PICKUP self.save() else: raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.") # def returning_shipping(self, int:id): # self.returning = True # if self.shipping_method == self.SHIPPING.ZASILKOVNA: # #volá se na api Zásilkovny # self.zasilkovna.get(id=id).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) #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( StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment" ) def save(self, *args, **kwargs): if self.order: order = Order.objects.get(payment=self) #validace platebních metod 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.") #validace dobírky (jestli není použitá pro osobní odběr) elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY and Carrier.objects.get(orders=order).shipping_method == Carrier.SHIPPING.STORE: raise ValueError("Dobírka není možná pro osobní odběr.") #vytvoření platebních metod pokud nový objekt if not self.pk: if self.payment_method == self.PAYMENT.STRIPE: self.stripe = StripePayment.objects.create(amount=order.total_price) else: self.stripe = None 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( validators=[MinValueValidator(0), MaxValueValidator(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 return True 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}" def save(self, *args, **kwargs): if self.pk is None: if self.order.payment.payment_method: raise ValueError("Nelze upravit položky z objednávky s již zvolenou platební metodou.") else: #nová položka objednávky, snížit skladové zásoby if self.product.stock < self.quantity: raise ValueError("Nedostatečný skladový zásob pro produkt.") self.product.stock -= self.quantity self.product.save(update_fields=["stock"]) super().save(*args, **kwargs) class Refund(models.Model): order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE) class Reason(models.TextChoices): RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty" DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt" WRONG_ITEM = "wrong_item", "Špatná položka" OTHER = "other", "Jiný důvod" reason_choice = models.CharField(max_length=30, choices=Reason.choices) reason_text = models.TextField(blank=True) verified = models.BooleanField(default=False) created_at = models.DateTimeField(auto_now_add=True) #VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW) # def save(self, *args, **kwargs): # # Automaticky aktualizovat stav objednávky na "vráceno" # if self.pk is None: # self.order.status = Order.Status.REFUNDING # self.order.save(update_fields=["status", "updated_at"]) # shipping_method = self.order.carrier.shipping_method # if shipping_method == Carrier.SHIPPING.ZASILKOVNA: # carrier = self.order.carrier; # # poslední odeslána/vytvořená zásilka # # Iniciovat vrácení přes Zásilkovnu # carrier.zasilkovna.latest('created_at').returning_packet() # carrier.save() # else: # # Logika pro jiné způsoby dopravy # pass # super().save(*args, **kwargs) def refund_completed(self): # Aktualizovat stav objednávky na "vráceno" self.order.payment.stripe.refund() self.order.status = Order.Status.REFUNDED self.order.save(update_fields=["status", "updated_at"]) def generate_refund_pdf_for_customer(self): """Vygeneruje PDF formulář k vrácení zboží pro zákazníka. Šablona refund/customer_in_package_returning_form.html očekává: - order: objekt objednávky - items: seznam položek (dict) s klíči product_name, sku, quantity, variant, options, reason - return_reason: textový důvod vrácení (kombinace reason_text / reason_choice) Návratová hodnota: bytes (PDF obsah). Uložení necháváme na volající logice. """ order = self.order # Připravíme položky pro šablonu (důvody per položku zatím None – lze rozšířit) prepared_items: list[dict] = [] for item in order.items.select_related('product'): prepared_items.append({ "product_name": getattr(item.product, "name", "Item"), "name": getattr(item.product, "name", "Item"), # fallbacky pro různé názvy v šabloně "sku": getattr(item.product, "code", None), "quantity": item.quantity, "variant": None, # lze doplnit pokud existují varianty "options": None, # lze doplnit pokud existují volby "reason": None, # per-item reason (zatím nepodporováno) }) return_reason = self.reason_text or self.get_reason_choice_display() context = { "order": order, "items": prepared_items, "return_reason": return_reason, } #TODO: přesunou zásilkovna field tady taky (ostranit z order několik balíku) html_string = render_to_string("refund/customer_in_package_returning_form.html", context) pdf_bytes = HTML(string=html_string).write_pdf() return pdf_bytes class Invoice(models.Model): invoice_number = models.CharField(max_length=50, unique=True) issued_at = models.DateTimeField(auto_now_add=True) due_date = models.DateTimeField() pdf_file = models.FileField(upload_to='invoices/') def __str__(self): return f"Invoice {self.invoice_number} for Order {self.order.id}" def generate_invoice_pdf(self): order = Order.objects.get(invoice=self) # Render HTML html_string = render_to_string("invoice/invoice.html", {"invoice": self}) pdf_bytes = HTML(string=html_string).write_pdf() # Save directly to FileField self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes)) self.save()