Files
orion/models/database/marketplace_product.py
Samir Boulahtit 8a2a955c92 feat: add VAT tax rate, cost, and Letzshop feed settings
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>
2025-12-20 21:17:41 +01:00

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