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:
291
docs/architecture/product-architecture.md
Normal file
291
docs/architecture/product-architecture.md
Normal 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`
|
||||
Reference in New Issue
Block a user