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

@@ -176,8 +176,8 @@ class CartService:
# Get current price in cents (use sale_price if available, otherwise regular price)
current_price_cents = (
product.effective_sale_price_cents
or product.effective_price_cents
product.sale_price_cents
or product.price_cents
or 0
)

View File

@@ -835,10 +835,18 @@ class MarketplaceProductService:
"""
Copy marketplace products to a vendor's catalog.
Creates independent vendor products with ALL fields copied from the
marketplace product. Each vendor product is a standalone entity - no
field inheritance or fallback logic. The marketplace_product_id FK is
kept for "view original source" feature.
Also copies ALL translations from the marketplace product.
Returns:
Dict with copied, skipped, failed counts and details
"""
from models.database.product import Product
from models.database.product_translation import ProductTranslation
from models.database.vendor import Vendor
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
@@ -849,6 +857,7 @@ class MarketplaceProductService:
marketplace_products = (
db.query(MarketplaceProduct)
.options(joinedload(MarketplaceProduct.translations))
.filter(MarketplaceProduct.id.in_(marketplace_product_ids))
.all()
)
@@ -883,19 +892,58 @@ class MarketplaceProductService:
)
continue
# Create vendor product with ALL fields copied from marketplace
product = Product(
vendor_id=vendor_id,
marketplace_product_id=mp.id,
# === Vendor settings (defaults) ===
is_active=True,
is_featured=False,
# Copy GTIN for order matching
# === Product identifiers ===
gtin=mp.gtin,
gtin_type=mp.gtin_type if hasattr(mp, "gtin_type") else None,
# === Pricing (copy from marketplace) ===
price_cents=mp.price_cents,
sale_price_cents=mp.sale_price_cents,
currency=mp.currency or "EUR",
# === Product info ===
brand=mp.brand,
condition=mp.condition,
availability=mp.availability,
# === Media ===
primary_image_url=mp.image_link,
additional_images=mp.additional_images,
# === Digital product fields ===
download_url=mp.download_url if hasattr(mp, "download_url") else None,
license_type=mp.license_type if hasattr(mp, "license_type") else None,
)
db.add(product)
db.flush() # Get product.id for translations
# Copy ALL translations from marketplace product
translations_copied = 0
for mpt in mp.translations:
product_translation = ProductTranslation(
product_id=product.id,
language=mpt.language,
title=mpt.title,
description=mpt.description,
short_description=mpt.short_description,
meta_title=mpt.meta_title,
meta_description=mpt.meta_description,
url_slug=mpt.url_slug,
)
db.add(product_translation)
translations_copied += 1
copied += 1
details.append({"id": mp.id, "status": "copied", "gtin": mp.gtin})
details.append({
"id": mp.id,
"status": "copied",
"gtin": mp.gtin,
"translations_copied": translations_copied,
})
except Exception as e:
logger.error(f"Failed to copy product {mp.id}: {str(e)}")

View File

@@ -340,8 +340,8 @@ class OrderService:
# Get price in cents (prefer sale price, then regular price)
unit_price_cents = (
product.effective_sale_price_cents
or product.effective_price_cents
product.sale_price_cents
or product.price_cents
)
if not unit_price_cents:
raise ValidationException(f"Product {product.id} has no price")

View File

@@ -171,9 +171,9 @@ class VendorProductService:
raise ProductNotFoundException(product_id)
mp = product.marketplace_product
override_info = product.get_override_info()
source_comparison_info = product.get_source_comparison_info()
# Get marketplace product translations
# Get marketplace product translations (for "view original source")
mp_translations = {}
if mp:
for t in mp.translations:
@@ -183,7 +183,7 @@ class VendorProductService:
"short_description": t.short_description,
}
# Get vendor translations (overrides)
# Get vendor translations
vendor_translations = {}
for t in product.translations:
vendor_translations[t.language] = {
@@ -198,8 +198,8 @@ class VendorProductService:
"vendor_code": product.vendor.vendor_code if product.vendor else None,
"marketplace_product_id": product.marketplace_product_id,
"vendor_sku": product.vendor_sku,
# Override info
**override_info,
# Product fields with source comparison info
**source_comparison_info,
# Vendor-specific fields
"is_featured": product.is_featured,
"is_active": product.is_active,
@@ -270,13 +270,13 @@ class VendorProductService:
"marketplace_product_id": product.marketplace_product_id,
"vendor_sku": product.vendor_sku,
"title": title,
"brand": product.effective_brand,
"effective_price": product.effective_price,
"effective_currency": product.effective_currency,
"brand": product.brand,
"price": product.price,
"currency": product.currency,
"is_active": product.is_active,
"is_featured": product.is_featured,
"is_digital": product.is_digital,
"image_url": product.effective_primary_image_url,
"image_url": product.primary_image_url,
"source_marketplace": mp.marketplace if mp else None,
"source_vendor": mp.vendor_name if mp else None,
"created_at": product.created_at.isoformat()