@@ -416,7 +416,7 @@
class="px-4 py-2 text-sm font-medium text-red-600 border border-red-600 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
:disabled="saving"
>
-
diff --git a/app/templates/vendor/product-create.html b/app/templates/vendor/product-create.html
new file mode 100644
index 00000000..55699c41
--- /dev/null
+++ b/app/templates/vendor/product-create.html
@@ -0,0 +1,174 @@
+{# app/templates/vendor/product-create.html #}
+{% extends "vendor/base.html" %}
+{% from 'shared/macros/headers.html' import detail_page_header %}
+
+{% block title %}Create Product{% endblock %}
+
+{% block alpine_data %}vendorProductCreate(){% endblock %}
+
+{% block content %}
+{% call detail_page_header("'Create Product'", backUrl) %}
+
Add a new product to your catalog
+{% endcall %}
+
+
+
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md
new file mode 100644
index 00000000..d64b8826
--- /dev/null
+++ b/docs/architecture/product-architecture.md
@@ -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`
diff --git a/mkdocs.yml b/mkdocs.yml
index 4b151532..2483bc26 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -34,6 +34,7 @@ nav:
- Architecture Patterns: architecture/architecture-patterns.md
- Multi-Tenant System: architecture/multi-tenant.md
- Marketplace Integration: architecture/marketplace-integration.md
+ - Product Architecture: architecture/product-architecture.md
- Language & i18n: architecture/language-i18n.md
- Money Handling: architecture/money-handling.md
- Company-Vendor Management: architecture/company-vendor-management.md
diff --git a/models/database/product.py b/models/database/product.py
index 35c69b0a..a07dcfbc 100644
--- a/models/database/product.py
+++ b/models/database/product.py
@@ -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,
}
diff --git a/models/schema/vendor_product.py b/models/schema/vendor_product.py
index a47a1aea..cfffca01 100644
--- a/models/schema/vendor_product.py
+++ b/models/schema/vendor_product.py
@@ -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
diff --git a/static/admin/js/vendor-product-create.js b/static/admin/js/vendor-product-create.js
index bdcccb7f..610f7e86 100644
--- a/static/admin/js/vendor-product-create.js
+++ b/static/admin/js/vendor-product-create.js
@@ -1,7 +1,7 @@
// static/admin/js/vendor-product-create.js
/**
* Admin vendor product create page logic
- * Create new vendor product entries
+ * Create new vendor product entries with translations
*/
const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate ||
@@ -12,6 +12,14 @@ adminVendorProductCreateLog.info('Loading...');
function adminVendorProductCreate() {
adminVendorProductCreateLog.info('adminVendorProductCreate() called');
+ // Default translations structure
+ const defaultTranslations = () => ({
+ en: { title: '', description: '' },
+ fr: { title: '', description: '' },
+ de: { title: '', description: '' },
+ lu: { title: '', description: '' }
+ });
+
return {
// Inherit base layout state
...data(),
@@ -26,20 +34,31 @@ function adminVendorProductCreate() {
// Tom Select instance
vendorSelectInstance: null,
+ // Active language tab
+ activeLanguage: 'en',
+
// Form data
form: {
vendor_id: null,
- title: '',
- brand: '',
+ // Translations by language
+ translations: defaultTranslations(),
+ // Product identifiers
vendor_sku: '',
+ brand: '',
gtin: '',
+ gtin_type: '',
+ // Pricing
price: null,
+ sale_price: null,
currency: 'EUR',
+ tax_rate_percent: 17,
availability: '',
+ // Image
+ primary_image_url: '',
+ // Status
is_active: true,
is_featured: false,
- is_digital: false,
- description: ''
+ is_digital: false
},
async init() {
@@ -111,6 +130,33 @@ function adminVendorProductCreate() {
adminVendorProductCreateLog.info('Vendor select initialized');
},
+ /**
+ * Generate a unique vendor SKU
+ * Format: XXXX_XXXX_XXXX (includes vendor_id for uniqueness)
+ */
+ generateSku() {
+ const vendorId = this.form.vendor_id || 0;
+
+ // Generate random alphanumeric segments
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+ const generateSegment = (length) => {
+ let result = '';
+ for (let i = 0; i < length; i++) {
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+ return result;
+ };
+
+ // First segment includes vendor ID (padded)
+ const vendorSegment = vendorId.toString().padStart(4, '0').slice(-4);
+
+ // Generate SKU: VID + random + random
+ const sku = `${vendorSegment}_${generateSegment(4)}_${generateSegment(4)}`;
+ this.form.vendor_sku = sku;
+
+ adminVendorProductCreateLog.info('Generated SKU:', sku);
+ },
+
/**
* Create the product
*/
@@ -120,30 +166,54 @@ function adminVendorProductCreate() {
return;
}
- if (!this.form.title) {
- Utils.showToast('Please enter a product title', 'error');
+ if (!this.form.translations.en.title?.trim()) {
+ Utils.showToast('Please enter a product title (English)', 'error');
return;
}
this.saving = true;
try {
+ // Build translations object for API (only include non-empty)
+ const translations = {};
+ for (const lang of ['en', 'fr', 'de', 'lu']) {
+ const t = this.form.translations[lang];
+ if (t.title?.trim() || t.description?.trim()) {
+ translations[lang] = {
+ title: t.title?.trim() || null,
+ description: t.description?.trim() || null
+ };
+ }
+ }
+
// Build create payload
const payload = {
vendor_id: this.form.vendor_id,
- title: this.form.title,
- brand: this.form.brand || null,
- vendor_sku: this.form.vendor_sku || null,
- gtin: this.form.gtin || null,
- price: this.form.price ? parseFloat(this.form.price) : null,
+ translations: Object.keys(translations).length > 0 ? translations : null,
+ // Product identifiers
+ brand: this.form.brand?.trim() || null,
+ vendor_sku: this.form.vendor_sku?.trim() || null,
+ gtin: this.form.gtin?.trim() || null,
+ gtin_type: this.form.gtin_type || null,
+ // Pricing
+ price: this.form.price !== null && this.form.price !== ''
+ ? parseFloat(this.form.price) : null,
+ sale_price: this.form.sale_price !== null && this.form.sale_price !== ''
+ ? parseFloat(this.form.sale_price) : null,
currency: this.form.currency || 'EUR',
+ tax_rate_percent: this.form.tax_rate_percent !== null
+ ? parseInt(this.form.tax_rate_percent) : 17,
availability: this.form.availability || null,
+ // Image
+ primary_image_url: this.form.primary_image_url?.trim() || null,
+ // Status
is_active: this.form.is_active,
is_featured: this.form.is_featured,
- is_digital: this.form.is_digital,
- description: this.form.description || null
+ is_digital: this.form.is_digital
};
+ adminVendorProductCreateLog.info('Creating product with payload:', payload);
+
const response = await apiClient.post('/admin/vendor-products', payload);
adminVendorProductCreateLog.info('Product created:', response.id);
diff --git a/static/admin/js/vendor-product-edit.js b/static/admin/js/vendor-product-edit.js
index 6dc69024..c2aa2b8c 100644
--- a/static/admin/js/vendor-product-edit.js
+++ b/static/admin/js/vendor-product-edit.js
@@ -59,6 +59,7 @@ function adminVendorProductEdit() {
sale_price: null,
currency: 'EUR',
tax_rate_percent: 17,
+ availability: '',
// Image
primary_image_url: '',
// Product type & status
@@ -125,6 +126,7 @@ function adminVendorProductEdit() {
sale_price: response.sale_price || null,
currency: response.currency || 'EUR',
tax_rate_percent: response.tax_rate_percent ?? 17,
+ availability: response.availability || '',
// Image
primary_image_url: response.primary_image_url || '',
// Product type & status
@@ -238,9 +240,11 @@ function adminVendorProductEdit() {
currency: this.form.currency || null,
tax_rate_percent: this.form.tax_rate_percent !== null && this.form.tax_rate_percent !== ''
? parseInt(this.form.tax_rate_percent) : null,
+ availability: this.form.availability || null,
// Image
primary_image_url: this.form.primary_image_url?.trim() || null,
- // Status (is_digital is derived from marketplace product, not editable)
+ // Status
+ is_digital: this.form.is_digital,
is_active: this.form.is_active,
is_featured: this.form.is_featured,
// Optional supplier info
diff --git a/static/vendor/js/product-create.js b/static/vendor/js/product-create.js
new file mode 100644
index 00000000..6c59d694
--- /dev/null
+++ b/static/vendor/js/product-create.js
@@ -0,0 +1,114 @@
+// static/vendor/js/product-create.js
+/**
+ * Vendor product creation page logic
+ */
+
+const vendorProductCreateLog = window.LogConfig.loggers.vendorProductCreate ||
+ window.LogConfig.createLogger('vendorProductCreate', false);
+
+vendorProductCreateLog.info('Loading...');
+
+function vendorProductCreate() {
+ vendorProductCreateLog.info('vendorProductCreate() called');
+
+ return {
+ // Inherit base layout state
+ ...data(),
+
+ // Set page identifier
+ currentPage: 'products',
+
+ // Back URL
+ get backUrl() {
+ return `/vendor/${this.vendorCode}/products`;
+ },
+
+ // Loading states
+ loading: false,
+ saving: false,
+ error: '',
+
+ // Form data
+ form: {
+ title: '',
+ brand: '',
+ vendor_sku: '',
+ gtin: '',
+ price: '',
+ currency: 'EUR',
+ availability: 'in_stock',
+ is_active: true,
+ is_featured: false,
+ is_digital: false,
+ description: ''
+ },
+
+ async init() {
+ // Guard against duplicate initialization
+ if (window._vendorProductCreateInitialized) return;
+ window._vendorProductCreateInitialized = true;
+
+ vendorProductCreateLog.info('Initializing product create page...');
+
+ try {
+ // IMPORTANT: Call parent init first to set vendorCode from URL
+ const parentInit = data().init;
+ if (parentInit) {
+ await parentInit.call(this);
+ }
+
+ vendorProductCreateLog.info('Product create page initialized');
+ } catch (err) {
+ vendorProductCreateLog.error('Failed to initialize:', err);
+ this.error = err.message || 'Failed to initialize';
+ }
+ },
+
+ async createProduct() {
+ if (!this.form.title || !this.form.price) {
+ this.showToast('Title and price are required', 'error');
+ return;
+ }
+
+ this.saving = true;
+ this.error = '';
+
+ try {
+ // Create product directly (vendor_id from JWT token)
+ const response = await apiClient.post('/vendor/products/create', {
+ title: this.form.title,
+ brand: this.form.brand || null,
+ vendor_sku: this.form.vendor_sku || null,
+ gtin: this.form.gtin || null,
+ price: parseFloat(this.form.price),
+ currency: this.form.currency,
+ availability: this.form.availability,
+ is_active: this.form.is_active,
+ is_featured: this.form.is_featured,
+ description: this.form.description || null
+ });
+
+ if (!response.ok) {
+ throw new Error(response.message || 'Failed to create product');
+ }
+
+ vendorProductCreateLog.info('Product created:', response.data);
+ this.showToast('Product created successfully', 'success');
+
+ // Navigate back to products list
+ setTimeout(() => {
+ window.location.href = this.backUrl;
+ }, 1000);
+
+ } catch (err) {
+ vendorProductCreateLog.error('Failed to create product:', err);
+ this.error = err.message || 'Failed to create product';
+ this.showToast(this.error, 'error');
+ } finally {
+ this.saving = false;
+ }
+ }
+ };
+}
+
+vendorProductCreateLog.info('Loaded successfully');
diff --git a/tests/integration/api/v1/admin/test_vendor_products.py b/tests/integration/api/v1/admin/test_vendor_products.py
index eed1c03f..4028fd77 100644
--- a/tests/integration/api/v1/admin/test_vendor_products.py
+++ b/tests/integration/api/v1/admin/test_vendor_products.py
@@ -210,3 +210,287 @@ class TestAdminVendorProductsAPI:
)
assert response.status_code == 403
+
+
+@pytest.mark.integration
+@pytest.mark.api
+@pytest.mark.admin
+@pytest.mark.products
+class TestAdminVendorProductCreateAPI:
+ """Tests for admin vendor product creation endpoints."""
+
+ def test_create_vendor_product_with_translations(
+ self, client, admin_headers, test_vendor
+ ):
+ """Test creating a product with multi-language translations."""
+ payload = {
+ "vendor_id": test_vendor.id,
+ "translations": {
+ "en": {"title": "Test Product EN", "description": "English description"},
+ "fr": {"title": "Test Product FR", "description": "French description"},
+ },
+ "vendor_sku": "CREATE_TEST_001",
+ "brand": "TestBrand",
+ "gtin": "1234567890123",
+ "gtin_type": "ean13",
+ "price": 29.99,
+ "currency": "EUR",
+ "tax_rate_percent": 17,
+ "is_active": True,
+ "is_digital": False,
+ }
+
+ response = client.post(
+ "/api/v1/admin/vendor-products",
+ json=payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert "id" in data
+ assert data["message"] == "Product created successfully"
+
+ # Verify the created product
+ product_id = data["id"]
+ detail_response = client.get(
+ f"/api/v1/admin/vendor-products/{product_id}",
+ headers=admin_headers,
+ )
+ assert detail_response.status_code == 200
+ detail = detail_response.json()
+ assert detail["vendor_id"] == test_vendor.id
+ assert detail["vendor_sku"] == "CREATE_TEST_001"
+ assert detail["brand"] == "TestBrand"
+ assert detail["is_digital"] is False
+ assert detail["vendor_translations"]["en"]["title"] == "Test Product EN"
+
+ def test_create_digital_product(self, client, admin_headers, test_vendor):
+ """Test creating a digital product directly."""
+ payload = {
+ "vendor_id": test_vendor.id,
+ "translations": {
+ "en": {"title": "Digital Game Key", "description": "Steam game key"},
+ },
+ "vendor_sku": "DIGITAL_001",
+ "price": 49.99,
+ "is_digital": True,
+ "is_active": True,
+ }
+
+ response = client.post(
+ "/api/v1/admin/vendor-products",
+ json=payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # Verify the product is digital
+ detail_response = client.get(
+ f"/api/v1/admin/vendor-products/{data['id']}",
+ headers=admin_headers,
+ )
+ detail = detail_response.json()
+ assert detail["is_digital"] is True
+ assert detail["product_type"] == "digital"
+
+ def test_create_product_without_marketplace_source(
+ self, client, admin_headers, test_vendor
+ ):
+ """Test creating a direct product without marketplace source."""
+ payload = {
+ "vendor_id": test_vendor.id,
+ "translations": {
+ "en": {"title": "Direct Product", "description": "Created directly"},
+ },
+ "vendor_sku": "DIRECT_001",
+ "brand": "DirectBrand",
+ "price": 19.99,
+ "is_active": True,
+ }
+
+ response = client.post(
+ "/api/v1/admin/vendor-products",
+ json=payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+
+ # Verify there's no marketplace source
+ detail_response = client.get(
+ f"/api/v1/admin/vendor-products/{data['id']}",
+ headers=admin_headers,
+ )
+ detail = detail_response.json()
+ assert detail["marketplace_product_id"] is None
+ assert detail["source_marketplace"] is None
+ assert detail["source_vendor"] is None
+
+ def test_create_product_non_admin(self, client, auth_headers, test_vendor):
+ """Test non-admin trying to create product."""
+ payload = {
+ "vendor_id": test_vendor.id,
+ "translations": {"en": {"title": "Test"}},
+ }
+
+ response = client.post(
+ "/api/v1/admin/vendor-products",
+ json=payload,
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 403
+
+
+@pytest.mark.integration
+@pytest.mark.api
+@pytest.mark.admin
+@pytest.mark.products
+class TestAdminVendorProductUpdateAPI:
+ """Tests for admin vendor product update endpoints."""
+
+ def test_update_vendor_product_translations(
+ self, client, admin_headers, test_vendor
+ ):
+ """Test updating product translations by first creating a product with translations."""
+ # First create a product with translations
+ create_payload = {
+ "vendor_id": test_vendor.id,
+ "translations": {
+ "en": {"title": "Original Title EN", "description": "Original desc"},
+ },
+ "vendor_sku": "TRANS_TEST_001",
+ "price": 10.00,
+ "is_active": True,
+ }
+ create_response = client.post(
+ "/api/v1/admin/vendor-products",
+ json=create_payload,
+ headers=admin_headers,
+ )
+ assert create_response.status_code == 200
+ product_id = create_response.json()["id"]
+
+ # Now update the translations
+ update_payload = {
+ "translations": {
+ "en": {"title": "Updated Title EN", "description": "Updated desc EN"},
+ "de": {"title": "Updated Title DE", "description": "Updated desc DE"},
+ }
+ }
+
+ response = client.patch(
+ f"/api/v1/admin/vendor-products/{product_id}",
+ json=update_payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+
+ # Re-fetch the product to verify translations were saved
+ detail_response = client.get(
+ f"/api/v1/admin/vendor-products/{product_id}",
+ headers=admin_headers,
+ )
+ assert detail_response.status_code == 200
+ data = detail_response.json()
+
+ # Check translations are present and updated
+ assert "vendor_translations" in data
+ assert data["vendor_translations"] is not None
+ assert "en" in data["vendor_translations"]
+ assert data["vendor_translations"]["en"]["title"] == "Updated Title EN"
+
+ def test_update_vendor_product_is_digital(
+ self, client, admin_headers, test_product, db
+ ):
+ """Test updating product is_digital flag."""
+ # First ensure it's not digital
+ test_product.is_digital = False
+ db.commit()
+
+ payload = {"is_digital": True}
+
+ response = client.patch(
+ f"/api/v1/admin/vendor-products/{test_product.id}",
+ json=payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["is_digital"] is True
+
+ def test_update_vendor_product_pricing(self, client, admin_headers, test_product):
+ """Test updating product pricing fields."""
+ payload = {
+ "price": 99.99,
+ "sale_price": 79.99,
+ "tax_rate_percent": 8,
+ "availability": "in_stock",
+ }
+
+ response = client.patch(
+ f"/api/v1/admin/vendor-products/{test_product.id}",
+ json=payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["price"] == 99.99
+ assert data["sale_price"] == 79.99
+ assert data["tax_rate_percent"] == 8
+ assert data["availability"] == "in_stock"
+
+ def test_update_vendor_product_identifiers(
+ self, client, admin_headers, test_product
+ ):
+ """Test updating product identifiers."""
+ payload = {
+ "vendor_sku": "UPDATED_SKU_001",
+ "brand": "UpdatedBrand",
+ "gtin": "9876543210123",
+ "gtin_type": "ean13",
+ }
+
+ response = client.patch(
+ f"/api/v1/admin/vendor-products/{test_product.id}",
+ json=payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["vendor_sku"] == "UPDATED_SKU_001"
+ assert data["brand"] == "UpdatedBrand"
+ assert data["gtin"] == "9876543210123"
+
+ def test_update_vendor_product_not_found(self, client, admin_headers):
+ """Test updating non-existent product."""
+ payload = {"brand": "Test"}
+
+ response = client.patch(
+ "/api/v1/admin/vendor-products/99999",
+ json=payload,
+ headers=admin_headers,
+ )
+
+ assert response.status_code == 404
+ assert response.json()["error_code"] == "PRODUCT_NOT_FOUND"
+
+ def test_update_vendor_product_non_admin(self, client, auth_headers, test_product):
+ """Test non-admin trying to update product."""
+ payload = {"brand": "Test"}
+
+ response = client.patch(
+ f"/api/v1/admin/vendor-products/{test_product.id}",
+ json=payload,
+ headers=auth_headers,
+ )
+
+ assert response.status_code == 403
diff --git a/tests/unit/models/database/test_product.py b/tests/unit/models/database/test_product.py
index 9f07b4c4..5b22a568 100644
--- a/tests/unit/models/database/test_product.py
+++ b/tests/unit/models/database/test_product.py
@@ -72,6 +72,8 @@ class TestProductModel:
assert product.is_active is True # Default
assert product.is_featured is False # Default
+ assert product.is_digital is False # Default
+ assert product.product_type == "physical" # Default
assert product.min_quantity == 1 # Default
assert product.display_order == 0 # Default
@@ -202,6 +204,82 @@ class TestProductModel:
assert info["price_source"] == 100.00
assert info["brand_source"] == "SourceBrand"
+ def test_product_direct_creation_without_marketplace(self, db, test_vendor):
+ """Test creating a product directly without a marketplace source.
+
+ Products can be created directly without a marketplace_product_id,
+ making them fully independent vendor products.
+ """
+ product = Product(
+ vendor_id=test_vendor.id,
+ marketplace_product_id=None, # No marketplace source
+ vendor_sku="DIRECT_001",
+ brand="DirectBrand",
+ price=59.99,
+ currency="EUR",
+ is_digital=True,
+ product_type="digital",
+ is_active=True,
+ )
+ db.add(product)
+ db.commit()
+ db.refresh(product)
+
+ assert product.id is not None
+ assert product.marketplace_product_id is None
+ assert product.marketplace_product is None
+ assert product.vendor_sku == "DIRECT_001"
+ assert product.brand == "DirectBrand"
+ assert product.is_digital is True
+ assert product.product_type == "digital"
+
+ def test_product_is_digital_column(self, db, test_vendor):
+ """Test is_digital is an independent column, not derived from marketplace."""
+ # Create digital product without marketplace source
+ digital_product = Product(
+ vendor_id=test_vendor.id,
+ vendor_sku="DIGITAL_001",
+ is_digital=True,
+ product_type="digital",
+ )
+ db.add(digital_product)
+ db.commit()
+ db.refresh(digital_product)
+
+ assert digital_product.is_digital is True
+ assert digital_product.product_type == "digital"
+
+ # Create physical product without marketplace source
+ physical_product = Product(
+ vendor_id=test_vendor.id,
+ vendor_sku="PHYSICAL_001",
+ is_digital=False,
+ product_type="physical",
+ )
+ db.add(physical_product)
+ db.commit()
+ db.refresh(physical_product)
+
+ assert physical_product.is_digital is False
+ assert physical_product.product_type == "physical"
+
+ def test_product_type_values(self, db, test_vendor):
+ """Test product_type can be set to various values."""
+ product_types = ["physical", "digital", "service", "subscription"]
+
+ for ptype in product_types:
+ product = Product(
+ vendor_id=test_vendor.id,
+ vendor_sku=f"TYPE_{ptype.upper()}",
+ product_type=ptype,
+ is_digital=(ptype == "digital"),
+ )
+ db.add(product)
+ db.commit()
+ db.refresh(product)
+
+ assert product.product_type == ptype
+
@pytest.mark.unit
@pytest.mark.database
@@ -209,17 +287,13 @@ class TestProductModel:
class TestProductInventoryProperties:
"""Test Product inventory properties including digital product handling."""
- def test_physical_product_no_inventory_returns_zero(
- self, db, test_vendor, test_marketplace_product
- ):
+ def test_physical_product_no_inventory_returns_zero(self, db, test_vendor):
"""Test physical product with no inventory entries returns 0."""
- # Ensure product is physical
- test_marketplace_product.is_digital = False
- db.commit()
-
product = Product(
vendor_id=test_vendor.id,
- marketplace_product_id=test_marketplace_product.id,
+ vendor_sku="PHYS_INV_001",
+ is_digital=False,
+ product_type="physical",
)
db.add(product)
db.commit()
@@ -230,18 +304,15 @@ class TestProductInventoryProperties:
assert product.total_inventory == 0
assert product.available_inventory == 0
- def test_physical_product_with_inventory(
- self, db, test_vendor, test_marketplace_product
- ):
+ def test_physical_product_with_inventory(self, db, test_vendor):
"""Test physical product calculates inventory from entries."""
from models.database.inventory import Inventory
- test_marketplace_product.is_digital = False
- db.commit()
-
product = Product(
vendor_id=test_vendor.id,
- marketplace_product_id=test_marketplace_product.id,
+ vendor_sku="PHYS_INV_002",
+ is_digital=False,
+ product_type="physical",
)
db.add(product)
db.commit()
@@ -274,16 +345,13 @@ class TestProductInventoryProperties:
assert product.total_inventory == 150 # 100 + 50
assert product.available_inventory == 135 # (100-10) + (50-5)
- def test_digital_product_has_unlimited_inventory(
- self, db, test_vendor, test_marketplace_product
- ):
+ def test_digital_product_has_unlimited_inventory(self, db, test_vendor):
"""Test digital product returns unlimited inventory."""
- test_marketplace_product.is_digital = True
- db.commit()
-
product = Product(
vendor_id=test_vendor.id,
- marketplace_product_id=test_marketplace_product.id,
+ vendor_sku="DIG_INV_001",
+ is_digital=True,
+ product_type="digital",
)
db.add(product)
db.commit()
@@ -294,18 +362,15 @@ class TestProductInventoryProperties:
assert product.total_inventory == Product.UNLIMITED_INVENTORY
assert product.available_inventory == Product.UNLIMITED_INVENTORY
- def test_digital_product_ignores_inventory_entries(
- self, db, test_vendor, test_marketplace_product
- ):
+ def test_digital_product_ignores_inventory_entries(self, db, test_vendor):
"""Test digital product returns unlimited even with inventory entries."""
from models.database.inventory import Inventory
- test_marketplace_product.is_digital = True
- db.commit()
-
product = Product(
vendor_id=test_vendor.id,
- marketplace_product_id=test_marketplace_product.id,
+ vendor_sku="DIG_INV_002",
+ is_digital=True,
+ product_type="digital",
)
db.add(product)
db.commit()