feat: make Product fully independent from MarketplaceProduct

- Add is_digital and product_type columns to Product model
- Remove is_digital/product_type properties that derived from MarketplaceProduct
- Update Create form with translation tabs, GTIN type, sale price, VAT rate, image
- Update Edit form to allow editing is_digital (remove disabled state)
- Add Availability field to Edit form
- Fix Detail page for directly created products (no marketplace source)
- Update vendor_product_service to handle new fields in create/update
- Add VendorProductCreate/Update schema fields for translations and is_digital
- Add unit tests for is_digital column and direct product creation
- Add integration tests for create/update API with new fields
- Create product-architecture.md documenting the independent copy pattern
- Add migration y3d4e5f6g7h8 for is_digital and product_type columns

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-08 01:11:00 +01:00
parent 7b81f59eba
commit fa2a3bf89a
19 changed files with 1603 additions and 201 deletions

View File

@@ -1,11 +1,11 @@
"""Vendor Product model - independent copy pattern.
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 model represents a vendor's product. Products can be:
1. Created from a marketplace import (has marketplace_product_id)
2. Created directly by the vendor (no marketplace_product_id)
The marketplace_product_id FK is kept for "view original source" feature,
allowing comparison with the original marketplace data.
When created from marketplace, the marketplace_product_id FK provides
"view original source" comparison feature.
Money values are stored as integer cents (e.g., €105.91 = 10591).
See docs/architecture/money-handling.md for details.
@@ -29,11 +29,10 @@ from models.database.base import TimestampMixin
class Product(Base, TimestampMixin):
"""Vendor-specific product - independent copy.
"""Vendor-specific 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.
Products can be created from marketplace imports or directly by vendors.
When from marketplace, marketplace_product_id provides source comparison.
Price fields use integer cents for precision (€19.99 = 1999 cents).
"""
@@ -43,7 +42,7 @@ class Product(Base, TimestampMixin):
id = Column(Integer, primary_key=True, index=True)
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
marketplace_product_id = Column(
Integer, ForeignKey("marketplace_products.id"), nullable=False
Integer, ForeignKey("marketplace_products.id"), nullable=True
)
# === VENDOR REFERENCE ===
@@ -85,7 +84,11 @@ class Product(Base, TimestampMixin):
cost_cents = Column(Integer) # What vendor pays to acquire (in cents) - for profit calculation
margin_percent_x100 = Column(Integer) # Markup percentage * 100 (e.g., 25.5% = 2550)
# === VENDOR-SPECIFIC (No inheritance) ===
# === PRODUCT TYPE ===
is_digital = Column(Boolean, default=False, index=True)
product_type = Column(String(20), default="physical") # physical, digital, service, subscription
# === VENDOR-SPECIFIC ===
is_featured = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
display_order = Column(Integer, default=0)
@@ -249,20 +252,6 @@ class Product(Base, TimestampMixin):
return None
return round((profit / net) * 100, 2)
# === MARKETPLACE PRODUCT PROPERTIES ===
@property
def is_digital(self) -> bool:
"""Check if this is a digital product."""
mp = self.marketplace_product
return mp.is_digital if mp else False
@property
def product_type(self) -> str:
"""Get product type from marketplace product."""
mp = self.marketplace_product
return mp.product_type_enum if mp else "physical"
# === INVENTORY PROPERTIES ===
# Constant for unlimited inventory (digital products)
@@ -305,6 +294,7 @@ class Product(Base, TimestampMixin):
Returns a dict with current field values and original source values
from the marketplace product. Used for "view original source" feature.
Only populated when product was created from a marketplace source.
"""
mp = self.marketplace_product
return {
@@ -331,7 +321,7 @@ class Product(Base, TimestampMixin):
# Images
"primary_image_url": self.primary_image_url,
"primary_image_url_source": mp.image_link if mp else None,
# Product type info
# Product type (independent fields, no source comparison)
"is_digital": self.is_digital,
"product_type": self.product_type,
}

View File

@@ -85,7 +85,7 @@ class VendorProductDetail(BaseModel):
vendor_id: int
vendor_name: str | None = None
vendor_code: str | None = None
marketplace_product_id: int
marketplace_product_id: int | None = None # Optional for direct product creation
vendor_sku: str | None = None
# Product identifiers
gtin: str | None = None
@@ -149,10 +149,46 @@ class RemoveProductResponse(BaseModel):
message: str
class TranslationUpdate(BaseModel):
"""Translation data for a single language."""
title: str | None = None
description: str | None = None
class VendorProductCreate(BaseModel):
"""Schema for creating a vendor product."""
"""Schema for creating a vendor product (admin use - includes vendor_id)."""
vendor_id: int
# Translations by language code (en, fr, de, lu)
translations: dict[str, TranslationUpdate] | None = None
# Product identifiers
brand: str | None = None
vendor_sku: str | None = None
gtin: str | None = None
gtin_type: str | None = None # ean13, ean8, upc, isbn
# Pricing
price: float | None = None
sale_price: float | None = None
currency: str = "EUR"
tax_rate_percent: int | None = 17 # Default Luxembourg VAT
availability: str | None = None
# Image
primary_image_url: str | None = None
# Status
is_active: bool = True
is_featured: bool = False
is_digital: bool = False
class VendorDirectProductCreate(BaseModel):
"""Schema for vendor direct product creation (vendor_id from JWT token)."""
title: str
brand: str | None = None
vendor_sku: str | None = None
@@ -162,14 +198,6 @@ class VendorProductCreate(BaseModel):
availability: str | None = None
is_active: bool = True
is_featured: bool = False
is_digital: bool = False
description: str | None = None
class TranslationUpdate(BaseModel):
"""Translation data for a single language."""
title: str | None = None
description: str | None = None
@@ -190,8 +218,10 @@ class VendorProductUpdate(BaseModel):
sale_price: float | None = None # Optional sale price
currency: str | None = None
tax_rate_percent: int | None = None # 3, 8, 14, 17
availability: str | None = None # in_stock, out_of_stock, preorder, backorder
# Status (is_digital is derived from marketplace product, not editable)
# Status
is_digital: bool | None = None
is_active: bool | None = None
is_featured: bool | None = None