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

@@ -0,0 +1,291 @@
# Product Architecture
## Overview
The product management system uses an **independent copy pattern** where vendor products (`Product`) are fully independent entities that can optionally reference a marketplace source (`MarketplaceProduct`) for display purposes only.
## Core Principles
| Principle | Description |
|-----------|-------------|
| **Full Independence** | Vendor products have all their own fields - no inheritance or fallback to marketplace |
| **Optional Source Reference** | `marketplace_product_id` is nullable - products can be created directly |
| **No Reset Functionality** | No "reset to source" - products are independent from the moment of creation |
| **Source for Display Only** | Source comparison info is read-only, used for "view original" display |
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ MarketplaceProduct │
│ (Central Repository - raw imported data from marketplaces) │
│ │
│ - marketplace_product_id (unique) │
│ - gtin, mpn, sku │
│ - brand, price_cents, sale_price_cents │
│ - is_digital, product_type_enum │
│ - translations (via MarketplaceProductTranslation) │
└──────────────────────────────────────────────────────────────────────┘
No runtime dependency
│ Optional FK (for "view source" display only)
│ marketplace_product_id (nullable)
┌─────────────────────────────────────────────────────────────────────┐
│ Product │
│ (Vendor's Independent Product - fully standalone) │
│ │
│ === IDENTIFIERS === │
│ - vendor_id (required) │
│ - vendor_sku │
│ - gtin, gtin_type │
│ │
│ === PRODUCT TYPE (own columns) === │
│ - is_digital (Boolean) │
│ - product_type (String: physical, digital, service, subscription) │
│ │
│ === PRICING === │
│ - price_cents, sale_price_cents │
│ - currency, tax_rate_percent │
│ │
│ === CONTENT === │
│ - brand, condition, availability │
│ - primary_image_url, additional_images │
│ - translations (via ProductTranslation) │
│ │
│ === STATUS === │
│ - is_active, is_featured │
│ │
│ === SUPPLIER === │
│ - supplier, cost_cents │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Product Creation Patterns
### 1. From Marketplace Source (Import)
When copying from a marketplace product:
- All fields are **copied** at creation time
- `marketplace_product_id` is set for source reference
- No ongoing relationship - product is immediately independent
```python
# Service copies all fields at import time
product = Product(
vendor_id=vendor.id,
marketplace_product_id=marketplace_product.id, # Source reference
# All fields copied - no inheritance
brand=marketplace_product.brand,
price=marketplace_product.price,
is_digital=marketplace_product.is_digital,
product_type=marketplace_product.product_type_enum,
# ... all other fields
)
```
### 2. Direct Creation (No Marketplace Source)
Vendors can create products directly without a marketplace source:
```python
product = Product(
vendor_id=vendor.id,
marketplace_product_id=None, # No source
vendor_sku="DIRECT_001",
brand="MyBrand",
price=29.99,
is_digital=True,
product_type="digital",
is_active=True,
)
```
---
## Key Fields
### Product Type Fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `is_digital` | Boolean | `False` | Whether product is digital (no physical shipping) |
| `product_type` | String(20) | `"physical"` | Product type: physical, digital, service, subscription |
These are **independent columns** on Product, not derived from MarketplaceProduct.
### Source Reference
| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `marketplace_product_id` | Integer FK | **Yes** | Optional reference to source MarketplaceProduct |
---
## Inventory Handling
Digital and physical products have different inventory behavior:
```python
@property
def has_unlimited_inventory(self) -> bool:
"""Digital products have unlimited inventory."""
return self.is_digital
@property
def total_inventory(self) -> int:
"""Get total inventory across all locations."""
if self.is_digital:
return Product.UNLIMITED_INVENTORY # 999999
return sum(inv.quantity for inv in self.inventory_entries)
```
---
## Source Comparison (Display Only)
For products with a marketplace source, we provide comparison info for display:
```python
def get_source_comparison_info(self) -> dict:
"""Get current values with source values for comparison.
Used for "view original source" display feature.
"""
mp = self.marketplace_product
return {
"price": self.price,
"price_source": mp.price if mp else None,
"brand": self.brand,
"brand_source": mp.brand if mp else None,
# ... other fields
}
```
This is **read-only** - there's no mechanism to "reset" to source values.
---
## UI Behavior
### Detail Page
| Product Type | Source Info Card | Edit Button Text |
|-------------|------------------|------------------|
| Marketplace-sourced | Shows source info with "View Source" link | "Edit Overrides" |
| Directly created | Shows "Direct Creation" badge | "Edit Product" |
### Info Banner
- **Marketplace-sourced**: Purple banner - "Vendor Product Catalog Entry"
- **Directly created**: Blue banner - "Directly Created Product"
---
## Database Schema
### Product Table Key Columns
```sql
CREATE TABLE products (
id INTEGER PRIMARY KEY,
vendor_id INTEGER NOT NULL REFERENCES vendors(id),
marketplace_product_id INTEGER REFERENCES marketplace_products(id), -- Nullable!
-- Product Type (independent columns)
is_digital BOOLEAN DEFAULT FALSE,
product_type VARCHAR(20) DEFAULT 'physical',
-- Identifiers
vendor_sku VARCHAR,
gtin VARCHAR,
gtin_type VARCHAR(10),
brand VARCHAR,
-- Pricing (in cents)
price_cents INTEGER,
sale_price_cents INTEGER,
currency VARCHAR(3) DEFAULT 'EUR',
tax_rate_percent INTEGER DEFAULT 17,
availability VARCHAR,
-- Media
primary_image_url VARCHAR,
additional_images JSON,
-- Status
is_active BOOLEAN DEFAULT TRUE,
is_featured BOOLEAN DEFAULT FALSE,
-- Timestamps
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- Index for product type queries
CREATE INDEX idx_product_is_digital ON products(is_digital);
```
---
## Migration History
| Migration | Description |
|-----------|-------------|
| `x2c3d4e5f6g7` | Made `marketplace_product_id` nullable |
| `y3d4e5f6g7h8` | Added `is_digital` and `product_type` columns to products |
---
## API Endpoints
### Create Product (Admin)
```
POST /api/v1/admin/vendor-products
{
"vendor_id": 1,
"translations": {
"en": {"title": "Product Name", "description": "..."},
"fr": {"title": "Nom du produit", "description": "..."}
},
"vendor_sku": "SKU001",
"brand": "BrandName",
"price": 29.99,
"is_digital": false,
"is_active": true
}
```
### Update Product (Admin)
```
PATCH /api/v1/admin/vendor-products/{id}
{
"is_digital": true,
"price": 39.99,
"translations": {
"en": {"title": "Updated Name"}
}
}
```
---
## Testing
Key test scenarios:
1. **Direct Product Creation** - Create without marketplace source
2. **Digital Product Inventory** - Verify unlimited inventory for digital
3. **is_digital Column** - Verify it's an independent column, not derived
4. **Source Comparison** - Verify read-only source info display
See:
- `tests/unit/models/database/test_product.py`
- `tests/integration/api/v1/admin/test_vendor_products.py`