"""Vendor Product model - independent copy pattern. This model represents a vendor's product. Products can be: 1. Created from a marketplace import (has marketplace_product_id) 2. Created directly by the vendor (no marketplace_product_id) When created from marketplace, the marketplace_product_id FK provides "view original source" comparison feature. Money values are stored as integer cents (e.g., €105.91 = 10591). See docs/architecture/money-handling.md for details. """ from sqlalchemy import ( Boolean, Column, ForeignKey, Index, Integer, String, UniqueConstraint, ) from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base from app.utils.money import cents_to_euros, euros_to_cents from models.database.base import TimestampMixin class Product(Base, TimestampMixin): """Vendor-specific product. Products can be created from marketplace imports or directly by vendors. When from marketplace, marketplace_product_id provides source comparison. Price fields use integer cents for precision (€19.99 = 1999 cents). """ __tablename__ = "products" id = Column(Integer, primary_key=True, index=True) vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) marketplace_product_id = Column( Integer, ForeignKey("marketplace_products.id"), nullable=True ) # === VENDOR REFERENCE === vendor_sku = Column(String, index=True) # Vendor's internal SKU # === PRODUCT IDENTIFIERS === # GTIN (Global Trade Item Number) - barcode for EAN matching with orders # Populated from MarketplaceProduct.gtin during product import gtin = Column(String(50), index=True) # EAN/UPC barcode number gtin_type = Column(String(20)) # Format: gtin13, gtin14, gtin12, gtin8, isbn13, isbn10 # === PRODUCT FIELDS (copied from marketplace at creation) === # Pricing - stored as integer cents (€19.99 = 1999) price_cents = Column(Integer) # Price in cents sale_price_cents = Column(Integer) # Sale price in cents currency = Column(String(3), default="EUR") # Product Info brand = Column(String) condition = Column(String) availability = Column(String) # Media primary_image_url = Column(String) additional_images = Column(JSON) # Digital Product Fields download_url = Column(String) license_type = Column(String(50)) # === TAX / VAT === # Luxembourg VAT rates: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard) # Prices are stored as gross (VAT-inclusive). Tax rate is used for profit calculation. tax_rate_percent = Column(Integer, default=17, nullable=False) # === SUPPLIER TRACKING & COST === supplier = Column(String(50)) # 'codeswholesale', 'internal', etc. supplier_product_id = Column(String) # Supplier's product reference cost_cents = Column(Integer) # What vendor pays to acquire (in cents) - for profit calculation margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550) # === PRODUCT TYPE === is_digital = Column(Boolean, default=False, index=True) product_type = Column(String(20), default="physical") # physical, digital, service, subscription # === VENDOR-SPECIFIC === is_featured = Column(Boolean, default=False) is_active = Column(Boolean, default=True) display_order = Column(Integer, default=0) # Inventory Settings min_quantity = Column(Integer, default=1) max_quantity = Column(Integer) # Digital Fulfillment fulfillment_email_template = Column(String) # Template name for digital delivery # === RELATIONSHIPS === vendor = relationship("Vendor", back_populates="products") marketplace_product = relationship( "MarketplaceProduct", back_populates="vendor_products" ) translations = relationship( "ProductTranslation", back_populates="product", cascade="all, delete-orphan", ) inventory_entries = relationship( "Inventory", back_populates="product", cascade="all, delete-orphan" ) # === CONSTRAINTS & INDEXES === __table_args__ = ( UniqueConstraint( "vendor_id", "marketplace_product_id", name="uq_vendor_marketplace_product" ), Index("idx_product_vendor_active", "vendor_id", "is_active"), Index("idx_product_vendor_featured", "vendor_id", "is_featured"), Index("idx_product_vendor_sku", "vendor_id", "vendor_sku"), Index("idx_product_supplier", "supplier", "supplier_product_id"), ) def __repr__(self): return ( f"" ) # === PRICE PROPERTIES (Euro convenience accessors) === @property def price(self) -> float | None: """Get price in euros (for API/display).""" if self.price_cents is not None: return cents_to_euros(self.price_cents) return None @price.setter def price(self, value: float | None): """Set price from euros.""" self.price_cents = euros_to_cents(value) if value is not None else None @property def sale_price(self) -> float | None: """Get sale price in euros (for API/display).""" if self.sale_price_cents is not None: return cents_to_euros(self.sale_price_cents) return None @sale_price.setter def sale_price(self, value: float | None): """Set sale price from euros.""" self.sale_price_cents = euros_to_cents(value) if value is not None else None @property def cost(self) -> float | None: """Get cost in euros (what vendor pays to acquire).""" if self.cost_cents is not None: return cents_to_euros(self.cost_cents) return None @cost.setter def cost(self, value: float | None): """Set cost from euros.""" self.cost_cents = euros_to_cents(value) if value is not None else None @property def margin_percent(self) -> float | None: """Get margin percent (e.g., 25.5).""" if self.margin_percent_x100 is not None: return self.margin_percent_x100 / 100.0 return None @margin_percent.setter def margin_percent(self, value: float | None): """Set margin percent.""" self.margin_percent_x100 = int(value * 100) if value is not None else None # === TAX / PROFIT CALCULATION PROPERTIES === @property def net_price_cents(self) -> int | None: """Calculate net price (excluding VAT) from gross price. Formula: Net = Gross / (1 + rate/100) Example: €119 gross at 17% VAT = €119 / 1.17 = €101.71 net """ if self.price_cents is None: return None # Use integer math to avoid floating point issues # Net = Gross * 100 / (100 + rate) return int(self.price_cents * 100 / (100 + self.tax_rate_percent)) @property def net_price(self) -> float | None: """Get net price in euros.""" cents = self.net_price_cents return cents_to_euros(cents) if cents is not None else None @property def vat_amount_cents(self) -> int | None: """Calculate VAT amount in cents. Formula: VAT = Gross - Net """ if self.price_cents is None: return None net = self.net_price_cents if net is None: return None return self.price_cents - net @property def vat_amount(self) -> float | None: """Get VAT amount in euros.""" cents = self.vat_amount_cents return cents_to_euros(cents) if cents is not None else None @property def profit_cents(self) -> int | None: """Calculate profit in cents. Formula: Profit = Net Revenue - Cost Returns None if cost is not set. """ net = self.net_price_cents if net is None or self.cost_cents is None: return None return net - self.cost_cents @property def profit(self) -> float | None: """Get profit in euros.""" cents = self.profit_cents return cents_to_euros(cents) if cents is not None else None @property def profit_margin_percent(self) -> float | None: """Calculate profit margin as percentage of net revenue. Formula: Margin% = (Profit / Net) * 100 Example: €41.71 profit on €101.71 net = 41.0% margin """ net = self.net_price_cents profit = self.profit_cents if net is None or profit is None or net == 0: return None return round((profit / net) * 100, 2) # === INVENTORY PROPERTIES === # Constant for unlimited inventory (digital products) UNLIMITED_INVENTORY = 999999 @property def has_unlimited_inventory(self) -> bool: """Check if product has unlimited inventory. Digital products have unlimited inventory by default. They don't require physical stock tracking. """ return self.is_digital @property def total_inventory(self) -> int: """Calculate total inventory across all locations. Digital products return unlimited inventory. """ if self.has_unlimited_inventory: return self.UNLIMITED_INVENTORY return sum(inv.quantity for inv in self.inventory_entries) @property def available_inventory(self) -> int: """Calculate available inventory (total - reserved). Digital products return unlimited inventory since they don't have physical stock constraints. """ if self.has_unlimited_inventory: return self.UNLIMITED_INVENTORY return sum(inv.available_quantity for inv in self.inventory_entries) # === SOURCE COMPARISON METHOD === def get_source_comparison_info(self) -> dict: """Get current values with source values for comparison. Returns a dict with current field values and original source values from the marketplace product. Used for "view original source" feature. Only populated when product was created from a marketplace source. """ mp = self.marketplace_product return { # Price "price": self.price, "price_cents": self.price_cents, "price_source": cents_to_euros(mp.price_cents) if mp and mp.price_cents else None, # Sale Price "sale_price": self.sale_price, "sale_price_cents": self.sale_price_cents, "sale_price_source": cents_to_euros(mp.sale_price_cents) if mp and mp.sale_price_cents else None, # Currency "currency": self.currency, "currency_source": mp.currency if mp else None, # Brand "brand": self.brand, "brand_source": mp.brand if mp else None, # Condition "condition": self.condition, "condition_source": mp.condition if mp else None, # Availability "availability": self.availability, "availability_source": mp.availability if mp else None, # Images "primary_image_url": self.primary_image_url, "primary_image_url_source": mp.image_link if mp else None, # Product type (independent fields, no source comparison) "is_digital": self.is_digital, "product_type": self.product_type, } # === TRANSLATION HELPERS === def get_translation(self, language: str) -> "ProductTranslation | None": """Get translation for a specific language.""" for t in self.translations: if t.language == language: return t return None def get_title(self, language: str = "en") -> str | None: """Get title for a specific language.""" translation = self.get_translation(language) return translation.title if translation else None def get_description(self, language: str = "en") -> str | None: """Get description for a specific language.""" translation = self.get_translation(language) return translation.description if translation else None