"""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. """ from sqlalchemy import ( Boolean, Column, Float, ForeignKey, Index, Integer, String, UniqueConstraint, ) from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import relationship from app.core.database import Base 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. """ __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 price = Column(Float) sale_price = Column(Float) 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)) # === SUPPLIER TRACKING === supplier = Column(String(50)) # 'codeswholesale', 'internal', etc. supplier_product_id = Column(String) # Supplier's product reference supplier_cost = Column(Float) # What we pay the supplier margin_percent = Column(Float) # Markup percentage # === 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", "sale_price", "currency", "brand", "condition", "availability", "primary_image_url", "additional_images", "download_url", "license_type", ] def __repr__(self): return ( f"" ) # === EFFECTIVE PROPERTIES (Override Pattern) === @property def effective_price(self) -> float | None: """Get price (vendor override or marketplace fallback).""" if self.price is not None: return self.price mp = self.marketplace_product return mp.price_numeric if mp else None @property def effective_sale_price(self) -> float | None: """Get sale price (vendor override or marketplace fallback).""" if self.sale_price is not None: return self.sale_price mp = self.marketplace_product return mp.sale_price_numeric if mp 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_overridden": self.price is not None, "price_source": mp.price_numeric if mp else None, # Sale Price "sale_price": self.effective_sale_price, "sale_price_overridden": self.sale_price is not None, "sale_price_source": mp.sale_price_numeric if mp 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