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