refactor: product independence - remove inheritance pattern

Change Product/ProductTranslation from "override/inheritance" pattern
(NULL = inherit from marketplace) to "independent copy" pattern
(all fields populated at creation).

Key changes:
- Remove OVERRIDABLE_FIELDS, effective_* properties, reset_* methods
- Rename get_override_info() → get_source_comparison_info()
- Update copy_to_vendor_catalog() to copy ALL fields + translations
- Replace effective_* with direct field access in services
- Remove *_overridden fields from schema, keep *_source for comparison
- Add migration to populate NULL fields from marketplace products

The marketplace_product_id FK is kept for "view original source" feature.
Rollback tag: v1.0.0-pre-product-independence

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-24 23:41:20 +01:00
parent 4ba911e263
commit 508e121a0e
10 changed files with 444 additions and 418 deletions

View File

@@ -1,12 +1,11 @@
"""Vendor Product model with override pattern.
"""Vendor Product model - independent copy 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 model represents a vendor's copy of a marketplace product. Products are
independent entities with all fields populated at creation time from the source
marketplace product.
This allows vendors to customize pricing, images, descriptions etc. while
still being able to "reset to source" by setting values back to NULL.
The marketplace_product_id FK is kept for "view original source" feature,
allowing comparison with the original marketplace data.
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
@@ -30,11 +29,11 @@ from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
"""Vendor-specific product with override capability.
"""Vendor-specific product - independent copy.
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.
Each vendor has their own copy of a marketplace product with all fields
populated at creation time. The marketplace_product_id FK is kept for
"view original source" comparison feature.
Price fields use integer cents for precision (€19.99 = 1999 cents).
"""
@@ -56,11 +55,11 @@ class Product(Base, TimestampMixin):
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) ===
# === 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))
currency = Column(String(3), default="EUR")
# Product Info
brand = Column(String)
@@ -71,7 +70,7 @@ class Product(Base, TimestampMixin):
primary_image_url = Column(String)
additional_images = Column(JSON)
# Digital Product Overrides
# Digital Product Fields
download_url = Column(String)
license_type = Column(String(50))
@@ -123,20 +122,6 @@ class Product(Base, TimestampMixin):
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"<Product(id={self.id}, vendor_id={self.vendor_id}, "
@@ -202,12 +187,11 @@ class Product(Base, TimestampMixin):
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:
if self.price_cents 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))
return int(self.price_cents * 100 / (100 + self.tax_rate_percent))
@property
def net_price(self) -> float | None:
@@ -221,11 +205,12 @@ class Product(Base, TimestampMixin):
Formula: VAT = Gross - Net
"""
gross = self.effective_price_cents
net = self.net_price_cents
if gross is None or net is None:
if self.price_cents is None:
return None
return gross - net
net = self.net_price_cents
if net is None:
return None
return self.price_cents - net
@property
def vat_amount(self) -> float | None:
@@ -264,85 +249,7 @@ class Product(Base, TimestampMixin):
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
# === MARKETPLACE PRODUCT PROPERTIES ===
@property
def is_digital(self) -> bool:
@@ -391,120 +298,59 @@ class Product(Base, TimestampMixin):
return self.UNLIMITED_INVENTORY
return sum(inv.available_quantity for inv in self.inventory_entries)
# === OVERRIDE INFO METHOD ===
# === SOURCE COMPARISON METHOD ===
def get_override_info(self) -> dict:
"""Get all fields with inheritance flags.
def get_source_comparison_info(self) -> dict:
"""Get current values with source values for comparison.
Returns a dict with effective values, override flags, and source values.
Similar to Vendor.get_contact_info_with_inheritance().
Returns a dict with current field values and original source values
from the marketplace product. Used for "view original source" feature.
"""
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": 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.effective_sale_price,
"sale_price_cents": self.effective_sale_price_cents,
"sale_price_overridden": self.sale_price_cents is not None,
"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.effective_currency,
"currency_overridden": self.currency is not None,
"currency": self.currency,
"currency_source": mp.currency if mp else None,
# Brand
"brand": self.effective_brand,
"brand_overridden": self.brand is not None,
"brand": self.brand,
"brand_source": mp.brand if mp else None,
# Condition
"condition": self.effective_condition,
"condition_overridden": self.condition is not None,
"condition": self.condition,
"condition_source": mp.condition if mp else None,
# Availability
"availability": self.effective_availability,
"availability_overridden": self.availability is not None,
"availability": self.availability,
"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": self.primary_image_url,
"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."""
"""Get 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
def get_title(self, language: str = "en") -> str | None:
"""Get title for a specific language."""
translation = self.get_translation(language)
if translation and translation.title:
return translation.title
return translation.title if translation else None
# 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
def get_description(self, language: str = "en") -> str | None:
"""Get description for a specific language."""
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
return translation.description if translation else None

View File

@@ -1,13 +1,11 @@
"""Product Translation model for vendor-specific localized content.
This model stores vendor-specific translations that can override the
marketplace product translations. The override pattern works as follows:
- NULL value = inherit from marketplace_product_translations
- Non-NULL value = vendor-specific override
This model stores vendor-specific translations. Translations are independent
entities with all fields populated at creation time from the source
marketplace product translation.
This allows vendors to customize titles, descriptions, and SEO content
per language while still being able to "reset to source" by setting
values back to NULL.
The marketplace product translation can be accessed via the product's
marketplace_product relationship for "view original source" comparison.
"""
from sqlalchemy import (
@@ -26,11 +24,11 @@ from models.database.base import TimestampMixin
class ProductTranslation(Base, TimestampMixin):
"""Vendor-specific localized content with override capability.
"""Vendor-specific localized content - independent copy.
Each vendor can have their own translations that override the
marketplace product translations. Fields set to NULL inherit
their value from the linked marketplace_product_translation.
Each vendor has their own translations with all fields populated
at creation time. The source marketplace translation can be accessed
for comparison via the product's marketplace_product relationship.
"""
__tablename__ = "product_translations"
@@ -43,12 +41,12 @@ class ProductTranslation(Base, TimestampMixin):
)
language = Column(String(5), nullable=False) # 'en', 'fr', 'de', 'lb'
# === OVERRIDABLE LOCALIZED FIELDS (NULL = inherit) ===
# === LOCALIZED FIELDS (copied from marketplace at creation) ===
title = Column(String)
description = Column(Text)
short_description = Column(String(500))
# SEO Overrides
# SEO Fields
meta_title = Column(String(70))
meta_description = Column(String(160))
url_slug = Column(String(255))
@@ -62,16 +60,6 @@ class ProductTranslation(Base, TimestampMixin):
Index("idx_pt_product_language", "product_id", "language"),
)
# === OVERRIDABLE FIELDS LIST ===
OVERRIDABLE_FIELDS = [
"title",
"description",
"short_description",
"meta_title",
"meta_description",
"url_slug",
]
def __repr__(self):
return (
f"<ProductTranslation(id={self.id}, "
@@ -80,10 +68,10 @@ class ProductTranslation(Base, TimestampMixin):
f"title='{self.title[:30] if self.title else None}...')>"
)
# === HELPER METHODS ===
# === SOURCE COMPARISON METHOD ===
def _get_marketplace_translation(self):
"""Get the corresponding marketplace translation for fallback."""
def _find_marketplace_translation(self):
"""Get the corresponding marketplace translation for comparison."""
product = self.product
if product and product.marketplace_product:
mp = product.marketplace_product
@@ -92,125 +80,35 @@ class ProductTranslation(Base, TimestampMixin):
return t
return None
def _get_marketplace_translation_field(self, field: str):
"""Helper to get a field from marketplace translation."""
mp_translation = self._get_marketplace_translation()
if mp_translation:
return getattr(mp_translation, field, None)
return None
def get_source_comparison_info(self) -> dict:
"""Get current values with source values for comparison.
# === EFFECTIVE PROPERTIES (Override Pattern) ===
def get_effective_title(self) -> str | None:
"""Get title with fallback to marketplace translation."""
if self.title is not None:
return self.title
return self._get_marketplace_translation_field("title")
def get_effective_description(self) -> str | None:
"""Get description with fallback to marketplace translation."""
if self.description is not None:
return self.description
return self._get_marketplace_translation_field("description")
def get_effective_short_description(self) -> str | None:
"""Get short description with fallback to marketplace translation."""
if self.short_description is not None:
return self.short_description
return self._get_marketplace_translation_field("short_description")
def get_effective_meta_title(self) -> str | None:
"""Get meta title with fallback to marketplace translation."""
if self.meta_title is not None:
return self.meta_title
return self._get_marketplace_translation_field("meta_title")
def get_effective_meta_description(self) -> str | None:
"""Get meta description with fallback to marketplace translation."""
if self.meta_description is not None:
return self.meta_description
return self._get_marketplace_translation_field("meta_description")
def get_effective_url_slug(self) -> str | None:
"""Get URL slug with fallback to marketplace translation."""
if self.url_slug is not None:
return self.url_slug
return self._get_marketplace_translation_field("url_slug")
# === OVERRIDE INFO METHOD ===
def get_override_info(self) -> dict:
"""Get all fields with inheritance flags.
Returns a dict with effective values and override flags.
Returns a dict with current field values and original source values
from the marketplace product translation. Used for "view original source" feature.
"""
mp_translation = self._get_marketplace_translation()
mp_translation = self._find_marketplace_translation()
return {
# Title
"title": self.get_effective_title(),
"title_overridden": self.title is not None,
"title": self.title,
"title_source": mp_translation.title if mp_translation else None,
# Description
"description": self.get_effective_description(),
"description_overridden": self.description is not None,
"description_source": mp_translation.description
if mp_translation
else None,
"description": self.description,
"description_source": mp_translation.description if mp_translation else None,
# Short Description
"short_description": self.get_effective_short_description(),
"short_description_overridden": self.short_description is not None,
"short_description": self.short_description,
"short_description_source": (
mp_translation.short_description if mp_translation else None
),
# Meta Title
"meta_title": self.get_effective_meta_title(),
"meta_title_overridden": self.meta_title is not None,
"meta_title": self.meta_title,
"meta_title_source": mp_translation.meta_title if mp_translation else None,
# Meta Description
"meta_description": self.get_effective_meta_description(),
"meta_description_overridden": self.meta_description is not None,
"meta_description": self.meta_description,
"meta_description_source": (
mp_translation.meta_description if mp_translation else None
),
# URL Slug
"url_slug": self.get_effective_url_slug(),
"url_slug_overridden": self.url_slug is not None,
"url_slug": self.url_slug,
"url_slug_source": mp_translation.url_slug if mp_translation else None,
}
# === RESET METHODS ===
def reset_field_to_source(self, field_name: str) -> bool:
"""Reset a single field to inherit from marketplace translation.
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_to_source(self) -> None:
"""Reset all fields to inherit from marketplace translation."""
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 translation.
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

View File

@@ -25,8 +25,8 @@ class VendorProductListItem(BaseModel):
vendor_sku: str | None = None
title: str | None = None
brand: str | None = None
effective_price: float | None = None
effective_currency: str | None = None
price: float | None = None
currency: str | None = None
is_active: bool | None = None
is_featured: bool | None = None
is_digital: bool | None = None
@@ -73,7 +73,11 @@ class CatalogVendorsResponse(BaseModel):
class VendorProductDetail(BaseModel):
"""Detailed vendor product information."""
"""Detailed vendor product information.
Products are independent entities - all fields are populated at creation.
Source values are kept for "view original source" comparison only.
"""
id: int
vendor_id: int
@@ -81,27 +85,22 @@ class VendorProductDetail(BaseModel):
vendor_code: str | None = None
marketplace_product_id: int
vendor_sku: str | None = None
# Override info from get_override_info()
# Product fields with source comparison
price: float | None = None
price_overridden: bool | None = None
price_source: float | None = None
price_cents: int | None = None
price_source: float | None = None # For "view original source" feature
sale_price: float | None = None
sale_price_overridden: bool | None = None
sale_price_cents: int | None = None
sale_price_source: float | None = None
currency: str | None = None
currency_overridden: bool | None = None
currency_source: str | None = None
brand: str | None = None
brand_overridden: bool | None = None
brand_source: str | None = None
condition: str | None = None
condition_overridden: bool | None = None
condition_source: str | None = None
availability: str | None = None
availability_overridden: bool | None = None
availability_source: str | None = None
primary_image_url: str | None = None
primary_image_url_overridden: bool | None = None
primary_image_url_source: str | None = None
is_digital: bool | None = None
product_type: str | None = None