Product Model: - Add tax_rate_percent (NOT NULL, default 17) for Luxembourg VAT - Add cost_cents for profit calculation - Add profit calculation properties: net_price, vat_amount, profit, margin - Rename supplier_cost_cents to cost_cents MarketplaceProduct Model: - Add tax_rate_percent (NOT NULL, default 17) Vendor Model (Letzshop feed settings): - letzshop_default_tax_rate: Default VAT for new products (0, 3, 8, 14, 17) - letzshop_boost_sort: Product sort priority (0.0-10.0) - letzshop_delivery_method: nationwide, package_delivery, self_collect - letzshop_preorder_days: Pre-order shipping delay VAT Strategy: - Store prices as gross (VAT-inclusive) for B2C - Calculate net from gross when needed for profit - Luxembourg VAT rates: 0%, 3%, 8%, 14%, 17% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""Marketplace Product model for multi-marketplace product integration.
|
|
|
|
This model stores canonical product data from various marketplaces (Letzshop,
|
|
Amazon, eBay, CodesWholesale, etc.) in a universal format. It supports:
|
|
- Physical and digital products
|
|
- Multi-language translations (via MarketplaceProductTranslation)
|
|
- Flexible attributes for marketplace-specific data
|
|
- Google Shopping fields for Letzshop compatibility
|
|
|
|
Money values are stored as integer cents (e.g., €105.91 = 10591).
|
|
Weight is stored as integer grams (e.g., 1.5kg = 1500g).
|
|
See docs/architecture/money-handling.md for details.
|
|
"""
|
|
|
|
from enum import Enum
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Column,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
)
|
|
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 ProductType(str, Enum):
|
|
"""Product type classification."""
|
|
|
|
PHYSICAL = "physical"
|
|
DIGITAL = "digital"
|
|
SERVICE = "service"
|
|
SUBSCRIPTION = "subscription"
|
|
|
|
|
|
class DigitalDeliveryMethod(str, Enum):
|
|
"""Digital product delivery methods."""
|
|
|
|
DOWNLOAD = "download"
|
|
EMAIL = "email"
|
|
IN_APP = "in_app"
|
|
STREAMING = "streaming"
|
|
LICENSE_KEY = "license_key"
|
|
|
|
|
|
class MarketplaceProduct(Base, TimestampMixin):
|
|
"""Canonical product data from marketplace sources.
|
|
|
|
This table stores normalized product information from all marketplace sources.
|
|
Localized content (title, description) is stored in MarketplaceProductTranslation.
|
|
|
|
Price fields use integer cents for precision (€19.99 = 1999 cents).
|
|
Weight uses integer grams (1.5kg = 1500 grams).
|
|
"""
|
|
|
|
__tablename__ = "marketplace_products"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
# === UNIVERSAL IDENTIFIERS ===
|
|
marketplace_product_id = Column(String, unique=True, index=True, nullable=False)
|
|
gtin = Column(String, index=True) # EAN/UPC - primary cross-marketplace matching
|
|
mpn = Column(String, index=True) # Manufacturer Part Number
|
|
sku = Column(String, index=True) # Internal SKU if assigned
|
|
|
|
# === SOURCE TRACKING ===
|
|
marketplace = Column(
|
|
String, index=True, nullable=True, default="letzshop"
|
|
) # 'letzshop', 'amazon', 'ebay', 'codeswholesale'
|
|
source_url = Column(String) # Original product URL
|
|
vendor_name = Column(String, index=True) # Seller/vendor in marketplace
|
|
|
|
# === PRODUCT TYPE ===
|
|
product_type_enum = Column(
|
|
String(20), nullable=False, default=ProductType.PHYSICAL.value
|
|
)
|
|
is_digital = Column(Boolean, default=False, index=True)
|
|
|
|
# === DIGITAL PRODUCT FIELDS ===
|
|
digital_delivery_method = Column(String(20)) # DigitalDeliveryMethod values
|
|
platform = Column(String(50), index=True) # 'steam', 'playstation', 'xbox', etc.
|
|
region_restrictions = Column(JSON) # ["EU", "US"] or null for global
|
|
license_type = Column(String(50)) # 'single_use', 'subscription', 'lifetime'
|
|
|
|
# === NON-LOCALIZED FIELDS ===
|
|
brand = Column(String, index=True)
|
|
google_product_category = Column(String, index=True)
|
|
category_path = Column(String) # Normalized category hierarchy
|
|
condition = Column(String)
|
|
|
|
# === PRICING (stored as integer cents) ===
|
|
price = Column(String) # Raw price string "19.99 EUR" (kept for reference)
|
|
price_cents = Column(Integer) # Parsed numeric price in cents
|
|
sale_price = Column(String) # Raw sale price string
|
|
sale_price_cents = Column(Integer) # Parsed numeric sale price in cents
|
|
currency = Column(String(3), default="EUR")
|
|
|
|
# === TAX / VAT ===
|
|
# Luxembourg VAT rates: 0 (exempt), 3 (super-reduced), 8 (reduced), 14 (intermediate), 17 (standard)
|
|
# Prices are stored as gross (VAT-inclusive). Default to standard rate.
|
|
tax_rate_percent = Column(Integer, default=17, nullable=False)
|
|
|
|
# === MEDIA ===
|
|
image_link = Column(String)
|
|
additional_image_link = Column(String) # Legacy single string
|
|
additional_images = Column(JSON) # Array of image URLs
|
|
|
|
# === PRODUCT ATTRIBUTES (Flexible) ===
|
|
attributes = Column(JSON) # {color, size, material, etc.}
|
|
|
|
# === PHYSICAL PRODUCT FIELDS ===
|
|
weight_grams = Column(Integer) # Weight in grams (1.5kg = 1500)
|
|
weight_unit = Column(String(10), default="kg") # Display unit
|
|
dimensions = Column(JSON) # {length, width, height, unit}
|
|
|
|
# === GOOGLE SHOPPING FIELDS (Preserved for Letzshop) ===
|
|
link = Column(String)
|
|
availability = Column(String, index=True)
|
|
adult = Column(String)
|
|
multipack = Column(Integer)
|
|
is_bundle = Column(String)
|
|
age_group = Column(String)
|
|
color = Column(String)
|
|
gender = Column(String)
|
|
material = Column(String)
|
|
pattern = Column(String)
|
|
size = Column(String)
|
|
size_type = Column(String)
|
|
size_system = Column(String)
|
|
item_group_id = Column(String)
|
|
product_type_raw = Column(String) # Original feed value (renamed from product_type)
|
|
custom_label_0 = Column(String)
|
|
custom_label_1 = Column(String)
|
|
custom_label_2 = Column(String)
|
|
custom_label_3 = Column(String)
|
|
custom_label_4 = Column(String)
|
|
unit_pricing_measure = Column(String)
|
|
unit_pricing_base_measure = Column(String)
|
|
identifier_exists = Column(String)
|
|
shipping = Column(String)
|
|
|
|
# === STATUS ===
|
|
is_active = Column(Boolean, default=True, index=True)
|
|
|
|
# === RELATIONSHIPS ===
|
|
translations = relationship(
|
|
"MarketplaceProductTranslation",
|
|
back_populates="marketplace_product",
|
|
cascade="all, delete-orphan",
|
|
)
|
|
vendor_products = relationship("Product", back_populates="marketplace_product")
|
|
|
|
# === INDEXES ===
|
|
__table_args__ = (
|
|
Index("idx_marketplace_vendor", "marketplace", "vendor_name"),
|
|
Index("idx_marketplace_brand", "marketplace", "brand"),
|
|
Index("idx_mp_gtin_marketplace", "gtin", "marketplace"),
|
|
Index("idx_mp_product_type", "product_type_enum", "is_digital"),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return (
|
|
f"<MarketplaceProduct(id={self.id}, "
|
|
f"marketplace_product_id='{self.marketplace_product_id}', "
|
|
f"marketplace='{self.marketplace}', "
|
|
f"vendor='{self.vendor_name}')>"
|
|
)
|
|
|
|
# === PRICE PROPERTIES (Euro convenience accessors) ===
|
|
|
|
@property
|
|
def price_numeric(self) -> float | None:
|
|
"""Get price in euros (for API/display). Legacy name for compatibility."""
|
|
if self.price_cents is not None:
|
|
return cents_to_euros(self.price_cents)
|
|
return None
|
|
|
|
@price_numeric.setter
|
|
def price_numeric(self, value: float | None):
|
|
"""Set price from euros. Legacy name for compatibility."""
|
|
self.price_cents = euros_to_cents(value) if value is not None else None
|
|
|
|
@property
|
|
def sale_price_numeric(self) -> float | None:
|
|
"""Get sale price in euros (for API/display). Legacy name for compatibility."""
|
|
if self.sale_price_cents is not None:
|
|
return cents_to_euros(self.sale_price_cents)
|
|
return None
|
|
|
|
@sale_price_numeric.setter
|
|
def sale_price_numeric(self, value: float | None):
|
|
"""Set sale price from euros. Legacy name for compatibility."""
|
|
self.sale_price_cents = euros_to_cents(value) if value is not None else None
|
|
|
|
@property
|
|
def weight(self) -> float | None:
|
|
"""Get weight in kg (for API/display)."""
|
|
if self.weight_grams is not None:
|
|
return self.weight_grams / 1000.0
|
|
return None
|
|
|
|
@weight.setter
|
|
def weight(self, value: float | None):
|
|
"""Set weight from kg."""
|
|
self.weight_grams = int(value * 1000) if value is not None else None
|
|
|
|
# === HELPER PROPERTIES ===
|
|
|
|
@property
|
|
def product_type(self) -> ProductType:
|
|
"""Get product type as enum."""
|
|
return ProductType(self.product_type_enum)
|
|
|
|
@product_type.setter
|
|
def product_type(self, value: ProductType | str):
|
|
"""Set product type from enum or string."""
|
|
if isinstance(value, ProductType):
|
|
self.product_type_enum = value.value
|
|
else:
|
|
self.product_type_enum = value
|
|
|
|
@property
|
|
def delivery_method(self) -> DigitalDeliveryMethod | None:
|
|
"""Get digital delivery method as enum."""
|
|
if self.digital_delivery_method:
|
|
return DigitalDeliveryMethod(self.digital_delivery_method)
|
|
return None
|
|
|
|
@delivery_method.setter
|
|
def delivery_method(self, value: DigitalDeliveryMethod | str | None):
|
|
"""Set delivery method from enum or string."""
|
|
if value is None:
|
|
self.digital_delivery_method = None
|
|
elif isinstance(value, DigitalDeliveryMethod):
|
|
self.digital_delivery_method = value.value
|
|
else:
|
|
self.digital_delivery_method = value
|
|
|
|
def get_translation(self, language: str) -> "MarketplaceProductTranslation | 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 with fallback to 'en'."""
|
|
translation = self.get_translation(language)
|
|
if translation:
|
|
return translation.title
|
|
|
|
# Fallback to English
|
|
if language != "en":
|
|
en_translation = self.get_translation("en")
|
|
if en_translation:
|
|
return en_translation.title
|
|
|
|
return None
|
|
|
|
def get_description(self, language: str = "en") -> str | None:
|
|
"""Get description for a specific language with fallback to 'en'."""
|
|
translation = self.get_translation(language)
|
|
if translation:
|
|
return translation.description
|
|
|
|
# Fallback to English
|
|
if language != "en":
|
|
en_translation = self.get_translation("en")
|
|
if en_translation:
|
|
return en_translation.description
|
|
|
|
return None
|
|
|
|
@property
|
|
def effective_price(self) -> float | None:
|
|
"""Get the effective numeric price in euros."""
|
|
return self.price_numeric
|
|
|
|
@property
|
|
def effective_sale_price(self) -> float | None:
|
|
"""Get the effective numeric sale price in euros."""
|
|
return self.sale_price_numeric
|
|
|
|
@property
|
|
def all_images(self) -> list[str]:
|
|
"""Get all product images as a list."""
|
|
images = []
|
|
if self.image_link:
|
|
images.append(self.image_link)
|
|
if self.additional_images:
|
|
images.extend(self.additional_images)
|
|
elif self.additional_image_link:
|
|
# Legacy single string format
|
|
images.append(self.additional_image_link)
|
|
return images
|