"""Vendor Product model with override pattern. This model represents a vendor's copy of a marketplace product with the ability to override any field. The override pattern works as follows: - NULL value = inherit from marketplace_product - Non-NULL value = vendor-specific override This allows vendors to customize pricing, images, descriptions etc. while still being able to "reset to source" by setting values back to NULL. 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 with override capability. Each vendor can have their own version of a marketplace product with custom pricing, images, and other overrides. Fields set to NULL inherit their value from the linked marketplace_product. 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=False ) # === 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 # === OVERRIDABLE FIELDS (NULL = inherit from marketplace_product) === # 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)) # Product Info brand = Column(String) condition = Column(String) availability = Column(String) # Media primary_image_url = Column(String) additional_images = Column(JSON) # Digital Product Overrides 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) # === VENDOR-SPECIFIC (No inheritance) === 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"), ) # === OVERRIDABLE FIELDS LIST === OVERRIDABLE_FIELDS = [ "price_cents", "sale_price_cents", "currency", "brand", "condition", "availability", "primary_image_url", "additional_images", "download_url", "license_type", ] 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 """ gross = self.effective_price_cents if gross is None: return None # Use integer math to avoid floating point issues # Net = Gross * 100 / (100 + rate) return int(gross * 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 """ gross = self.effective_price_cents net = self.net_price_cents if gross is None or net is None: return None return gross - 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) # === EFFECTIVE PROPERTIES (Override Pattern) === @property def effective_price_cents(self) -> int | None: """Get price in cents (vendor override or marketplace fallback).""" if self.price_cents is not None: return self.price_cents mp = self.marketplace_product return mp.price_cents if mp else None @property def effective_price(self) -> float | None: """Get price in euros (vendor override or marketplace fallback).""" cents = self.effective_price_cents return cents_to_euros(cents) if cents is not None else None @property def effective_sale_price_cents(self) -> int | None: """Get sale price in cents (vendor override or marketplace fallback).""" if self.sale_price_cents is not None: return self.sale_price_cents mp = self.marketplace_product return mp.sale_price_cents if mp else None @property def effective_sale_price(self) -> float | None: """Get sale price in euros (vendor override or marketplace fallback).""" cents = self.effective_sale_price_cents return cents_to_euros(cents) if cents is not None else None @property def effective_currency(self) -> str: """Get currency (vendor override or marketplace fallback).""" if self.currency is not None: return self.currency mp = self.marketplace_product return mp.currency if mp else "EUR" @property def effective_brand(self) -> str | None: """Get brand (vendor override or marketplace fallback).""" if self.brand is not None: return self.brand mp = self.marketplace_product return mp.brand if mp else None @property def effective_condition(self) -> str | None: """Get condition (vendor override or marketplace fallback).""" if self.condition is not None: return self.condition mp = self.marketplace_product return mp.condition if mp else None @property def effective_availability(self) -> str | None: """Get availability (vendor override or marketplace fallback).""" if self.availability is not None: return self.availability mp = self.marketplace_product return mp.availability if mp else None @property def effective_primary_image_url(self) -> str | None: """Get primary image (vendor override or marketplace fallback).""" if self.primary_image_url is not None: return self.primary_image_url mp = self.marketplace_product return mp.image_link if mp else None @property def effective_additional_images(self) -> list | None: """Get additional images (vendor override or marketplace fallback).""" if self.additional_images is not None: return self.additional_images mp = self.marketplace_product if mp and mp.additional_images: return mp.additional_images return None @property def is_digital(self) -> bool: """Check if this is a digital product.""" mp = self.marketplace_product return mp.is_digital if mp else False @property def product_type(self) -> str: """Get product type from marketplace product.""" mp = self.marketplace_product return mp.product_type_enum if mp else "physical" # === 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) # === OVERRIDE INFO METHOD === def get_override_info(self) -> dict: """Get all fields with inheritance flags. Returns a dict with effective values, override flags, and source values. Similar to Vendor.get_contact_info_with_inheritance(). """ mp = self.marketplace_product return { # Price "price": self.effective_price, "price_cents": self.effective_price_cents, "price_overridden": self.price_cents is not None, "price_source": cents_to_euros(mp.price_cents) if mp and mp.price_cents else None, # Sale Price "sale_price": self.effective_sale_price, "sale_price_cents": self.effective_sale_price_cents, "sale_price_overridden": self.sale_price_cents is not None, "sale_price_source": cents_to_euros(mp.sale_price_cents) if mp and mp.sale_price_cents else None, # Currency "currency": self.effective_currency, "currency_overridden": self.currency is not None, "currency_source": mp.currency if mp else None, # Brand "brand": self.effective_brand, "brand_overridden": self.brand is not None, "brand_source": mp.brand if mp else None, # Condition "condition": self.effective_condition, "condition_overridden": self.condition is not None, "condition_source": mp.condition if mp else None, # Availability "availability": self.effective_availability, "availability_overridden": self.availability is not None, "availability_source": mp.availability if mp else None, # Images "primary_image_url": self.effective_primary_image_url, "primary_image_url_overridden": self.primary_image_url is not None, "primary_image_url_source": mp.image_link if mp else None, # Product type info "is_digital": self.is_digital, "product_type": self.product_type, } # === RESET METHODS === def reset_field_to_source(self, field_name: str) -> bool: """Reset a single field to inherit from marketplace product. Args: field_name: Name of the field to reset Returns: True if field was reset, False if field is not overridable """ if field_name in self.OVERRIDABLE_FIELDS: setattr(self, field_name, None) return True return False def reset_all_to_source(self) -> None: """Reset all overridable fields to inherit from marketplace product.""" for field in self.OVERRIDABLE_FIELDS: setattr(self, field, None) def reset_fields_to_source(self, field_names: list[str]) -> list[str]: """Reset multiple fields to inherit from marketplace product. Args: field_names: List of field names to reset Returns: List of fields that were successfully reset """ reset_fields = [] for field in field_names: if self.reset_field_to_source(field): reset_fields.append(field) return reset_fields # === TRANSLATION HELPERS === def get_translation(self, language: str) -> "ProductTranslation | None": """Get vendor translation for a specific language.""" for t in self.translations: if t.language == language: return t return None def get_effective_title(self, language: str = "en") -> str | None: """Get title with vendor override or marketplace fallback.""" # Check vendor translation first translation = self.get_translation(language) if translation and translation.title: return translation.title # Fall back to marketplace translation mp = self.marketplace_product if mp: return mp.get_title(language) return None def get_effective_description(self, language: str = "en") -> str | None: """Get description with vendor override or marketplace fallback.""" # Check vendor translation first translation = self.get_translation(language) if translation and translation.description: return translation.description # Fall back to marketplace translation mp = self.marketplace_product if mp: return mp.get_description(language) return None