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,43 @@
# alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py
"""Make marketplace_product_id nullable for direct product creation.
Revision ID: x2c3d4e5f6g7
Revises: w1b2c3d4e5f6
Create Date: 2026-01-06 23:15:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "x2c3d4e5f6g7"
down_revision: str = "w1b2c3d4e5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Make marketplace_product_id nullable to allow direct product creation."""
# SQLite doesn't support ALTER COLUMN, so we need to recreate the table
# For SQLite, we use batch mode which handles this automatically
with op.batch_alter_table("products") as batch_op:
batch_op.alter_column(
"marketplace_product_id",
existing_type=sa.Integer(),
nullable=True,
)
def downgrade() -> None:
"""Revert marketplace_product_id to NOT NULL."""
# Note: This will fail if there are any NULL values in the column
with op.batch_alter_table("products") as batch_op:
batch_op.alter_column(
"marketplace_product_id",
existing_type=sa.Integer(),
nullable=False,
)

View File

@@ -0,0 +1,47 @@
# alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py
"""Add is_digital and product_type columns to products table.
Makes Product fully independent from MarketplaceProduct for product type info.
Revision ID: y3d4e5f6g7h8
Revises: x2c3d4e5f6g7
Create Date: 2026-01-07 10:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "y3d4e5f6g7h8"
down_revision: str = "x2c3d4e5f6g7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add is_digital and product_type columns to products table."""
with op.batch_alter_table("products") as batch_op:
batch_op.add_column(
sa.Column("is_digital", sa.Boolean(), nullable=False, server_default="0")
)
batch_op.add_column(
sa.Column(
"product_type",
sa.String(20),
nullable=False,
server_default="physical",
)
)
batch_op.create_index("idx_product_is_digital", ["is_digital"])
def downgrade() -> None:
"""Remove is_digital and product_type columns."""
with op.batch_alter_table("products") as batch_op:
batch_op.drop_index("idx_product_is_digital")
batch_op.drop_column("product_type")
batch_op.drop_column("is_digital")

View File

@@ -15,6 +15,7 @@ from app.api.deps import get_current_vendor_api
from app.core.database import get_db from app.core.database import get_db
from app.services.product_service import product_service from app.services.product_service import product_service
from app.services.subscription_service import subscription_service from app.services.subscription_service import subscription_service
from app.services.vendor_product_service import vendor_product_service
from models.database.user import User from models.database.user import User
from models.schema.product import ( from models.schema.product import (
ProductCreate, ProductCreate,
@@ -25,6 +26,10 @@ from models.schema.product import (
ProductToggleResponse, ProductToggleResponse,
ProductUpdate, ProductUpdate,
) )
from models.schema.vendor_product import (
VendorDirectProductCreate,
VendorProductCreateResponse,
)
router = APIRouter(prefix="/products") router = APIRouter(prefix="/products")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -106,6 +111,50 @@ def add_product_to_catalog(
return ProductResponse.model_validate(product) return ProductResponse.model_validate(product)
@router.post("/create", response_model=VendorProductCreateResponse)
def create_product_direct(
product_data: VendorDirectProductCreate,
current_user: User = Depends(get_current_vendor_api),
db: Session = Depends(get_db),
):
"""
Create a new product directly without marketplace product.
This creates a Product and ProductTranslation without requiring
an existing MarketplaceProduct.
"""
# Check product limit before creating
subscription_service.check_product_limit(db, current_user.token_vendor_id)
# Build data dict with vendor_id from token
data = {
"vendor_id": current_user.token_vendor_id,
"title": product_data.title,
"brand": product_data.brand,
"vendor_sku": product_data.vendor_sku,
"gtin": product_data.gtin,
"price": product_data.price,
"currency": product_data.currency,
"availability": product_data.availability,
"is_active": product_data.is_active,
"is_featured": product_data.is_featured,
"description": product_data.description,
}
product = vendor_product_service.create_product(db=db, data=data)
db.commit()
logger.info(
f"Product {product.id} created by user {current_user.username} "
f"for vendor {current_user.token_vendor_code}"
)
return VendorProductCreateResponse(
id=product.id,
message="Product created successfully",
)
@router.put("/{product_id}", response_model=ProductResponse) @router.put("/{product_id}", response_model=ProductResponse)
def update_product( def update_product(
product_id: int, product_id: int,

View File

@@ -280,6 +280,25 @@ async def vendor_products_page(
) )
@router.get(
"/{vendor_code}/products/create", response_class=HTMLResponse, include_in_schema=False
)
async def vendor_product_create_page(
request: Request,
vendor_code: str = Path(..., description="Vendor code"),
current_user: User = Depends(get_current_vendor_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render product creation page.
JavaScript handles form submission via API.
"""
return templates.TemplateResponse(
"vendor/product-create.html",
get_vendor_context(request, db, current_user, vendor_code),
)
# ============================================================================ # ============================================================================
# ORDER MANAGEMENT # ORDER MANAGEMENT
# ============================================================================ # ============================================================================

View File

@@ -47,6 +47,7 @@ class VendorProductService:
.options( .options(
joinedload(Product.vendor), joinedload(Product.vendor),
joinedload(Product.marketplace_product), joinedload(Product.marketplace_product),
joinedload(Product.translations),
) )
) )
@@ -246,26 +247,67 @@ class VendorProductService:
Args: Args:
db: Database session db: Database session
data: Product data dict data: Product data dict (includes translations dict for multiple languages)
Returns: Returns:
Created Product instance Created Product instance
""" """
from models.database.product_translation import ProductTranslation
# Determine product_type from is_digital flag
is_digital = data.get("is_digital", False)
product_type = "digital" if is_digital else data.get("product_type", "physical")
product = Product( product = Product(
vendor_id=data["vendor_id"], vendor_id=data["vendor_id"],
vendor_sku=data.get("vendor_sku"), vendor_sku=data.get("vendor_sku"),
brand=data.get("brand"), brand=data.get("brand"),
gtin=data.get("gtin"), gtin=data.get("gtin"),
price=data.get("price"), gtin_type=data.get("gtin_type"),
currency=data.get("currency", "EUR"), currency=data.get("currency", "EUR"),
tax_rate_percent=data.get("tax_rate_percent", 17),
availability=data.get("availability"), availability=data.get("availability"),
primary_image_url=data.get("primary_image_url"),
is_active=data.get("is_active", True), is_active=data.get("is_active", True),
is_featured=data.get("is_featured", False), is_featured=data.get("is_featured", False),
is_digital=data.get("is_digital", False), is_digital=is_digital,
description=data.get("description"), product_type=product_type,
) )
# Handle price fields via setters (convert to cents)
if data.get("price") is not None:
product.price = data["price"]
if data.get("sale_price") is not None:
product.sale_price = data["sale_price"]
db.add(product) db.add(product)
db.flush() # Get the product ID
# Handle translations dict (new format with multiple languages)
translations = data.get("translations")
if translations:
for lang, trans_data in translations.items():
if trans_data and (trans_data.get("title") or trans_data.get("description")):
translation = ProductTranslation(
product_id=product.id,
language=lang,
title=trans_data.get("title"),
description=trans_data.get("description"),
)
db.add(translation)
else:
# Fallback for old format with single title/description
title = data.get("title")
description = data.get("description")
if title or description:
translation = ProductTranslation(
product_id=product.id,
language="en",
title=title,
description=description,
)
db.add(translation)
db.flush() db.flush()
logger.info(f"Created vendor product {product.id} for vendor {data['vendor_id']}") logger.info(f"Created vendor product {product.id} for vendor {data['vendor_id']}")
@@ -327,7 +369,6 @@ class VendorProductService:
product.cost = data["cost"] # Uses property setter product.cost = data["cost"] # Uses property setter
# Update other allowed fields # Update other allowed fields
# Note: is_digital is derived from marketplace_product, not directly updatable
updatable_fields = [ updatable_fields = [
"vendor_sku", "vendor_sku",
"brand", "brand",
@@ -335,6 +376,8 @@ class VendorProductService:
"gtin_type", "gtin_type",
"currency", "currency",
"tax_rate_percent", "tax_rate_percent",
"availability",
"is_digital",
"is_active", "is_active",
"is_featured", "is_featured",
"primary_image_url", "primary_image_url",
@@ -370,9 +413,22 @@ class VendorProductService:
"""Build a product list item dict.""" """Build a product list item dict."""
mp = product.marketplace_product mp = product.marketplace_product
# Get title from marketplace product translations # Get title: prefer vendor translations, fallback to marketplace translations
title = None title = None
if mp: # First try vendor's own translations
if product.translations:
for trans in product.translations:
if trans.language == language and trans.title:
title = trans.title
break
# Fallback to English if requested language not found
if not title:
for trans in product.translations:
if trans.language == "en" and trans.title:
title = trans.title
break
# Fallback to marketplace translations
if not title and mp:
title = mp.get_title(language) title = mp.get_title(language)
return { return {

View File

@@ -61,28 +61,88 @@
Vendor Vendor
</h3> </h3>
<div class="max-w-md"> <div class="max-w-md">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Select Vendor *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Select Vendor <span class="text-red-500">*</span></label>
<select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor..."> <select id="vendor-select" x-ref="vendorSelect" placeholder="Search vendor...">
</select> </select>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">The vendor whose catalog this product will be added to</p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">The vendor whose catalog this product will be added to</p>
</div> </div>
</div> </div>
<!-- Basic Information --> <!-- Product Information (Translations) -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800"> <div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200"> <h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Basic Information Product Information <span class="text-red-500">*</span>
</h3> </h3>
<div class="grid gap-4 md:grid-cols-2">
<!-- Language Tabs -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-4">
<nav class="flex space-x-4">
<template x-for="lang in ['en', 'fr', 'de', 'lu']" :key="lang">
<button
type="button"
@click="activeLanguage = lang"
:class="activeLanguage === lang ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400'"
class="py-2 px-1 border-b-2 font-medium text-sm uppercase"
x-text="lang"
></button>
</template>
</nav>
</div>
<!-- Translation Fields -->
<template x-for="lang in ['en', 'fr', 'de', 'lu']" :key="lang">
<div x-show="activeLanguage === lang" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Title (<span x-text="lang.toUpperCase()"></span>) <span x-show="lang === 'en'" class="text-red-500">*</span>
</label>
<input
type="text"
x-model="form.translations[lang].title"
:required="lang === 'en'"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Description (<span x-text="lang.toUpperCase()"></span>)
</label>
<textarea
x-model="form.translations[lang].description"
rows="5"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product description (HTML supported)"
></textarea>
</div>
</div>
</template>
</div>
<!-- Product Identifiers -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Identifiers
</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Title *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Vendor SKU</label>
<input <div class="flex gap-2">
type="text" <input
x-model="form.title" type="text"
required x-model="form.vendor_sku"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300" class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
placeholder="Product title" placeholder="XXXX_XXXX_XXXX"
/> />
<button
type="button"
@click="generateSku()"
class="px-3 py-2 text-xs font-medium text-purple-600 dark:text-purple-400 border border-purple-300 dark:border-purple-600 rounded-lg hover:bg-purple-50 dark:hover:bg-purple-900/20"
title="Auto-generate SKU"
>
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
</button>
</div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Brand</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Brand</label>
@@ -93,24 +153,28 @@
placeholder="Brand name" placeholder="Brand name"
/> />
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Vendor SKU</label>
<input
type="text"
x-model="form.vendor_sku"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="SKU"
/>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN/EAN</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN/EAN</label>
<input <input
type="text" type="text"
x-model="form.gtin" x-model="form.gtin"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300" class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300 font-mono"
placeholder="GTIN/EAN" placeholder="4007817144145"
/> />
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN Type</label>
<select
x-model="form.gtin_type"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Not specified</option>
<option value="ean13">EAN-13</option>
<option value="ean8">EAN-8</option>
<option value="upc">UPC</option>
<option value="isbn">ISBN</option>
</select>
</div>
</div> </div>
</div> </div>
@@ -119,17 +183,35 @@
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200"> <h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing Pricing
</h3> </h3>
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{# noqa: FE-008 - Using raw number input for price field #} {# noqa: FE-008 - Using raw number input for price with EUR prefix #}
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price (incl. VAT)</label>
<input <div class="relative">
type="number" <span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
step="0.01" <input
x-model="form.price" type="number"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300" step="0.01"
placeholder="0.00" min="0"
/> x-model.number="form.price"
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Sale Price</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3 text-gray-500 dark:text-gray-400">EUR</span>
<input
type="number"
step="0.01"
min="0"
x-model.number="form.sale_price"
class="w-full pl-12 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Currency</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Currency</label>
@@ -142,6 +224,18 @@
<option value="GBP">GBP</option> <option value="GBP">GBP</option>
</select> </select>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">VAT Rate</label>
<select
x-model.number="form.tax_rate_percent"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="17">17% (Standard)</option>
<option value="14">14% (Intermediate)</option>
<option value="8">8% (Reduced)</option>
<option value="3">3% (Super-reduced)</option>
</select>
</div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label>
<select <select
@@ -158,12 +252,51 @@
</div> </div>
</div> </div>
<!-- Status --> <!-- Image -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800"> <div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200"> <h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Status Primary Image
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Image URL</label>
<input
type="url"
x-model="form.primary_image_url"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="https://..."
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Preview</label>
<div class="w-24 h-24 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600">
<template x-if="form.primary_image_url">
<img :src="form.primary_image_url" class="w-full h-full object-cover" />
</template>
<template x-if="!form.primary_image_url">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-8 h-8 text-gray-400')"></span>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Product Type & Status -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Type & Status
</h3> </h3>
<div class="flex flex-wrap gap-6"> <div class="flex flex-wrap gap-6">
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_digital"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
</label>
<label class="flex items-center"> <label class="flex items-center">
<input <input
type="checkbox" type="checkbox"
@@ -180,30 +313,9 @@
/> />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span> <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
</label> </label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_digital"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
</label>
</div> </div>
</div> </div>
<!-- Description -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Description
</h3>
<textarea
x-model="form.description"
rows="6"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product description (HTML supported)"
></textarea>
</div>
<!-- Actions --> <!-- Actions -->
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800"> <div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
<a <a
@@ -214,7 +326,7 @@
</a> </a>
<button <button
type="submit" type="submit"
:disabled="saving || !form.vendor_id" :disabled="saving || !form.vendor_id || !form.translations.en.title"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50" class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
> >
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span> <span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>

View File

@@ -21,17 +21,33 @@
<!-- Product Details --> <!-- Product Details -->
<div x-show="!loading && product"> <div x-show="!loading && product">
<!-- Override Info Banner --> <!-- Info Banner - adapts based on whether product has marketplace source -->
<div class="px-4 py-3 mb-6 bg-purple-50 dark:bg-purple-900/20 rounded-lg shadow-md"> <div class="px-4 py-3 mb-6 rounded-lg shadow-md"
:class="product?.marketplace_product_id ? 'bg-purple-50 dark:bg-purple-900/20' : 'bg-blue-50 dark:bg-blue-900/20'">
<div class="flex items-start"> <div class="flex items-start">
<span x-html="$icon('information-circle', 'w-5 h-5 text-purple-500 mr-3 mt-0.5 flex-shrink-0')"></span> <span x-html="$icon('information-circle', product?.marketplace_product_id ? 'w-5 h-5 text-purple-500 mr-3 mt-0.5 flex-shrink-0' : 'w-5 h-5 text-blue-500 mr-3 mt-0.5 flex-shrink-0')"></span>
<div> <div>
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Vendor Product Catalog Entry</p> <!-- Marketplace-sourced product -->
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1"> <template x-if="product?.marketplace_product_id">
This is a vendor-specific copy of a marketplace product. Fields marked with <div>
<span class="inline-flex items-center px-1.5 py-0.5 mx-1 text-xs font-medium text-purple-700 bg-purple-100 dark:bg-purple-800 dark:text-purple-300 rounded">Override</span> <p class="text-sm font-medium text-purple-700 dark:text-purple-300">Vendor Product Catalog Entry</p>
have been customized for this vendor. <p class="text-xs text-purple-600 dark:text-purple-400 mt-1">
</p> This is a vendor-specific copy of a marketplace product. Fields marked with
<span class="inline-flex items-center px-1.5 py-0.5 mx-1 text-xs font-medium text-purple-700 bg-purple-100 dark:bg-purple-800 dark:text-purple-300 rounded">Override</span>
have been customized for this vendor.
</p>
</div>
</template>
<!-- Directly created product -->
<template x-if="!product?.marketplace_product_id">
<div>
<p class="text-sm font-medium text-blue-700 dark:text-blue-300">Directly Created Product</p>
<p class="text-xs text-blue-600 dark:text-blue-400 mt-1">
This product was created directly for this vendor without a marketplace source.
All product information is managed independently.
</p>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -42,18 +58,20 @@
Quick Actions Quick Actions
</h3> </h3>
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<!-- View Source Product - only for marketplace-sourced products -->
<a <a
x-show="product?.marketplace_product_id"
:href="'/admin/marketplace-products/' + product?.marketplace_product_id" :href="'/admin/marketplace-products/' + product?.marketplace_product_id"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"> class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
<span x-html="$icon('database', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('database', 'w-4 h-4 mr-2')"></span>
View Source Product View Source Product
</a> </a>
<button <a
@click="openEditModal()" :href="'/admin/vendor-products/' + productId + '/edit'"
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"> class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600">
<span x-html="$icon('pencil', 'w-4 h-4 mr-2')"></span> <span x-html="$icon('pencil', 'w-4 h-4 mr-2')"></span>
Edit Overrides <span x-text="product?.marketplace_product_id ? 'Edit Overrides' : 'Edit Product'"></span>
</button> </a>
<button <button
@click="toggleActive()" @click="toggleActive()"
:class="product?.is_active :class="product?.is_active
@@ -194,7 +212,7 @@
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200"> <h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Identifiers Product Identifiers
</h3> </h3>
<div class="grid gap-4 md:grid-cols-4"> <div class="grid gap-4" :class="product?.marketplace_product_id ? 'md:grid-cols-4' : 'md:grid-cols-3'">
<div> <div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product ID</p> <p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Product ID</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.id || '-'">-</p> <p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.id || '-'">-</p>
@@ -207,41 +225,62 @@
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor SKU</p> <p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Vendor SKU</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_sku || '-'">-</p> <p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.vendor_sku || '-'">-</p>
</div> </div>
<div> <!-- Source SKU - only for marketplace-sourced products -->
<div x-show="product?.marketplace_product_id">
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source SKU</p> <p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source SKU</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.source_sku || '-'">-</p> <p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.source_sku || '-'">-</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Source Information Card --> <!-- Source Information Card - only for marketplace-sourced products -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800"> <template x-if="product?.marketplace_product_id">
<div class="flex items-center justify-between mb-4"> <div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"> <div class="flex items-center justify-between mb-4">
Source Information <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
Source Information
</h3>
<a
:href="'/admin/marketplace-products/' + product?.marketplace_product_id"
class="flex items-center text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400">
<span>View Source</span>
<span x-html="$icon('arrow-right', 'w-4 h-4 ml-1')"></span>
</a>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_marketplace || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source Vendor</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_vendor || '-'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace Product ID</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.marketplace_product_id || '-'">-</p>
</div>
</div>
</div>
</template>
<!-- Product Origin Card - for directly created products -->
<template x-if="!product?.marketplace_product_id">
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Product Origin
</h3> </h3>
<a <div class="flex items-center gap-3">
:href="'/admin/marketplace-products/' + product?.marketplace_product_id" <span class="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30">
class="flex items-center text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400"> <span x-html="$icon('plus-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
<span>View Source</span> </span>
<span x-html="$icon('arrow-right', 'w-4 h-4 ml-1')"></span> <div>
</a> <p class="text-sm font-medium text-gray-700 dark:text-gray-300">Direct Creation</p>
</div> <p class="text-xs text-gray-500 dark:text-gray-400">This product was created directly in the vendor's catalog</p>
<div class="grid gap-4 md:grid-cols-3"> </div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_marketplace || 'Unknown'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Source Vendor</p>
<p class="text-sm text-gray-700 dark:text-gray-300" x-text="product?.source_vendor || 'Unknown'">-</p>
</div>
<div>
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase">Marketplace Product ID</p>
<p class="text-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.marketplace_product_id || '-'">-</p>
</div> </div>
</div> </div>
</div> </template>
<!-- Description Card --> <!-- Description Card -->
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.title || product?.description"> <div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800" x-show="product?.title || product?.description">

View File

@@ -146,7 +146,7 @@
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200"> <h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing Pricing
</h3> </h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{# noqa: FE-008 - Using raw number input for price with EUR prefix #} {# noqa: FE-008 - Using raw number input for price with EUR prefix #}
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
@@ -210,6 +210,21 @@
<option value="3">3% (Super-reduced)</option> <option value="3">3% (Super-reduced)</option>
</select> </select>
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">
Availability
</label>
<select
x-model="form.availability"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="">Not specified</option>
<option value="in_stock">In Stock</option>
<option value="out_of_stock">Out of Stock</option>
<option value="preorder">Preorder</option>
<option value="backorder">Backorder</option>
</select>
</div>
</div> </div>
</div> </div>
@@ -253,14 +268,13 @@
Product Type & Status Product Type & Status
</h3> </h3>
<div class="flex flex-wrap gap-6"> <div class="flex flex-wrap gap-6">
<label class="flex items-center opacity-60 cursor-not-allowed" title="Derived from marketplace product"> <label class="flex items-center">
<input <input
type="checkbox" type="checkbox"
x-model="form.is_digital" x-model="form.is_digital"
disabled class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 cursor-not-allowed"
/> />
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product <span class="text-xs text-gray-500">(from source)</span></span> <span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
</label> </label>
<label class="flex items-center"> <label class="flex items-center">
<input <input

View File

@@ -44,7 +44,7 @@
<!-- Images --> <!-- Images -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500"> <div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
<span x-html="$icon('image', 'w-5 h-5')"></span> <span x-html="$icon('photograph', 'w-5 h-5')"></span>
</div> </div>
<div> <div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Images</p> <p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Images</p>
@@ -55,7 +55,7 @@
<!-- Videos --> <!-- Videos -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500"> <div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
<span x-html="$icon('video', 'w-5 h-5')"></span> <span x-html="$icon('play', 'w-5 h-5')"></span>
</div> </div>
<div> <div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Videos</p> <p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Videos</p>
@@ -66,7 +66,7 @@
<!-- Documents --> <!-- Documents -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800"> <div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500"> <div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
<span x-html="$icon('file-text', 'w-5 h-5')"></span> <span x-html="$icon('document-text', 'w-5 h-5')"></span>
</div> </div>
<div> <div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Documents</p> <p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Documents</p>
@@ -128,7 +128,7 @@
<!-- Empty State --> <!-- Empty State -->
<div x-show="media.length === 0" class="bg-white rounded-lg shadow-md dark:bg-gray-800 p-12 text-center"> <div x-show="media.length === 0" class="bg-white rounded-lg shadow-md dark:bg-gray-800 p-12 text-center">
<div class="text-gray-400 mb-4"> <div class="text-gray-400 mb-4">
<span x-html="$icon('image', 'w-16 h-16 mx-auto')"></span> <span x-html="$icon('photograph', 'w-16 h-16 mx-auto')"></span>
</div> </div>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">No Media Files Yet</h3> <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-2">No Media Files Yet</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">Upload your first file to get started</p> <p class="text-gray-600 dark:text-gray-400 mb-4">Upload your first file to get started</p>
@@ -163,14 +163,14 @@
<!-- Video icon --> <!-- Video icon -->
<template x-if="item.media_type === 'video'"> <template x-if="item.media_type === 'video'">
<div class="w-full h-full flex items-center justify-center text-gray-400"> <div class="w-full h-full flex items-center justify-center text-gray-400">
<span x-html="$icon('video', 'w-12 h-12')"></span> <span x-html="$icon('play', 'w-12 h-12')"></span>
</div> </div>
</template> </template>
<!-- Document icon --> <!-- Document icon -->
<template x-if="item.media_type === 'document'"> <template x-if="item.media_type === 'document'">
<div class="w-full h-full flex items-center justify-center text-gray-400"> <div class="w-full h-full flex items-center justify-center text-gray-400">
<span x-html="$icon('file-text', 'w-12 h-12')"></span> <span x-html="$icon('document-text', 'w-12 h-12')"></span>
</div> </div>
</template> </template>
@@ -251,7 +251,7 @@
> >
<div class="text-gray-400 mb-4"> <div class="text-gray-400 mb-4">
<span x-html="$icon('upload-cloud', 'w-12 h-12 mx-auto')"></span> <span x-html="$icon('cloud-upload', 'w-12 h-12 mx-auto')"></span>
</div> </div>
<p class="text-gray-600 dark:text-gray-400 mb-2">Drag and drop files here, or</p> <p class="text-gray-600 dark:text-gray-400 mb-2">Drag and drop files here, or</p>
<button <button
@@ -270,7 +270,7 @@
<template x-for="file in uploadingFiles" :key="file.name"> <template x-for="file in uploadingFiles" :key="file.name">
<div class="flex items-center gap-3 p-2 bg-gray-50 dark:bg-gray-700 rounded"> <div class="flex items-center gap-3 p-2 bg-gray-50 dark:bg-gray-700 rounded">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<span x-html="$icon(file.status === 'success' ? 'check-circle' : file.status === 'error' ? 'x-circle' : 'loader', 'w-5 h-5')" <span x-html="$icon(file.status === 'success' ? 'check-circle' : file.status === 'error' ? 'x-circle' : 'spinner', 'w-5 h-5')"
:class="{ :class="{
'text-green-500': file.status === 'success', 'text-green-500': file.status === 'success',
'text-red-500': file.status === 'error', 'text-red-500': file.status === 'error',
@@ -325,7 +325,7 @@
</template> </template>
<template x-if="selectedMedia?.media_type !== 'image'"> <template x-if="selectedMedia?.media_type !== 'image'">
<div class="aspect-square flex items-center justify-center text-gray-400"> <div class="aspect-square flex items-center justify-center text-gray-400">
<span x-html="$icon(selectedMedia?.media_type === 'video' ? 'video' : 'file-text', 'w-16 h-16')"></span> <span x-html="$icon(selectedMedia?.media_type === 'video' ? 'play' : 'document-text', 'w-16 h-16')"></span>
</div> </div>
</template> </template>
</div> </div>
@@ -401,7 +401,7 @@
class="px-3 py-2 text-sm border rounded-lg hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700" class="px-3 py-2 text-sm border rounded-lg hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
title="Copy URL" title="Copy URL"
> >
<span x-html="$icon('copy', 'w-4 h-4')"></span> <span x-html="$icon('clipboard-copy', 'w-4 h-4')"></span>
</button> </button>
</div> </div>
</div> </div>
@@ -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" 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" :disabled="saving"
> >
<span x-html="$icon('trash-2', 'w-4 h-4 inline mr-1')"></span> <span x-html="$icon('trash', 'w-4 h-4 inline mr-1')"></span>
Delete Delete
</button> </button>
<div class="flex gap-3"> <div class="flex gap-3">

174
app/templates/vendor/product-create.html vendored Normal file
View File

@@ -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) %}
<span>Add a new product to your catalog</span>
{% endcall %}
<!-- Create Form -->
<form @submit.prevent="createProduct()">
<!-- Basic Information -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Basic Information
</h3>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Title *</label>
<input
type="text"
x-model="form.title"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product title"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Brand</label>
<input
type="text"
x-model="form.brand"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Brand name"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">SKU</label>
<input
type="text"
x-model="form.vendor_sku"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="SKU"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">GTIN/EAN</label>
<input
type="text"
x-model="form.gtin"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="GTIN/EAN"
/>
</div>
</div>
</div>
<!-- Pricing -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Pricing
</h3>
<div class="grid gap-4 md:grid-cols-3">
{# noqa: FE-008 - Using raw number input for price field #}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price *</label>
<input
type="number"
step="0.01"
x-model="form.price"
required
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="0.00"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Currency</label>
<select
x-model="form.currency"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label>
<select
x-model="form.availability"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
>
<option value="in_stock">In Stock</option>
<option value="out_of_stock">Out of Stock</option>
<option value="preorder">Preorder</option>
<option value="backorder">Backorder</option>
</select>
</div>
</div>
</div>
<!-- Status -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Status
</h3>
<div class="flex flex-wrap gap-6">
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_active"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_featured"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Featured</span>
</label>
<label class="flex items-center">
<input
type="checkbox"
x-model="form.is_digital"
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Digital Product</span>
</label>
</div>
</div>
<!-- Description -->
<div class="px-4 py-5 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
Description
</h3>
<textarea
x-model="form.description"
rows="6"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none dark:bg-gray-700 dark:text-gray-300"
placeholder="Product description"
></textarea>
</div>
<!-- Actions -->
<div class="flex items-center justify-between px-4 py-4 bg-white rounded-lg shadow-md dark:bg-gray-800">
<a
:href="backUrl"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</a>
<button
type="submit"
:disabled="saving || !form.title"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2 animate-spin')"></span>
<span x-text="saving ? 'Creating...' : 'Create Product'"></span>
</button>
</div>
</form>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='vendor/js/product-create.js') }}"></script>
{% endblock %}

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`

View File

@@ -34,6 +34,7 @@ nav:
- Architecture Patterns: architecture/architecture-patterns.md - Architecture Patterns: architecture/architecture-patterns.md
- Multi-Tenant System: architecture/multi-tenant.md - Multi-Tenant System: architecture/multi-tenant.md
- Marketplace Integration: architecture/marketplace-integration.md - Marketplace Integration: architecture/marketplace-integration.md
- Product Architecture: architecture/product-architecture.md
- Language & i18n: architecture/language-i18n.md - Language & i18n: architecture/language-i18n.md
- Money Handling: architecture/money-handling.md - Money Handling: architecture/money-handling.md
- Company-Vendor Management: architecture/company-vendor-management.md - Company-Vendor Management: architecture/company-vendor-management.md

View File

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

View File

@@ -85,7 +85,7 @@ class VendorProductDetail(BaseModel):
vendor_id: int vendor_id: int
vendor_name: str | None = None vendor_name: str | None = None
vendor_code: 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 vendor_sku: str | None = None
# Product identifiers # Product identifiers
gtin: str | None = None gtin: str | None = None
@@ -149,10 +149,46 @@ class RemoveProductResponse(BaseModel):
message: str message: str
class TranslationUpdate(BaseModel):
"""Translation data for a single language."""
title: str | None = None
description: str | None = None
class VendorProductCreate(BaseModel): class VendorProductCreate(BaseModel):
"""Schema for creating a vendor product.""" """Schema for creating a vendor product (admin use - includes vendor_id)."""
vendor_id: int 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 title: str
brand: str | None = None brand: str | None = None
vendor_sku: str | None = None vendor_sku: str | None = None
@@ -162,14 +198,6 @@ class VendorProductCreate(BaseModel):
availability: str | None = None availability: str | None = None
is_active: bool = True is_active: bool = True
is_featured: bool = False 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 description: str | None = None
@@ -190,8 +218,10 @@ class VendorProductUpdate(BaseModel):
sale_price: float | None = None # Optional sale price sale_price: float | None = None # Optional sale price
currency: str | None = None currency: str | None = None
tax_rate_percent: int | None = None # 3, 8, 14, 17 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_active: bool | None = None
is_featured: bool | None = None is_featured: bool | None = None

View File

@@ -1,7 +1,7 @@
// static/admin/js/vendor-product-create.js // static/admin/js/vendor-product-create.js
/** /**
* Admin vendor product create page logic * Admin vendor product create page logic
* Create new vendor product entries * Create new vendor product entries with translations
*/ */
const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate || const adminVendorProductCreateLog = window.LogConfig.loggers.adminVendorProductCreate ||
@@ -12,6 +12,14 @@ adminVendorProductCreateLog.info('Loading...');
function adminVendorProductCreate() { function adminVendorProductCreate() {
adminVendorProductCreateLog.info('adminVendorProductCreate() called'); adminVendorProductCreateLog.info('adminVendorProductCreate() called');
// Default translations structure
const defaultTranslations = () => ({
en: { title: '', description: '' },
fr: { title: '', description: '' },
de: { title: '', description: '' },
lu: { title: '', description: '' }
});
return { return {
// Inherit base layout state // Inherit base layout state
...data(), ...data(),
@@ -26,20 +34,31 @@ function adminVendorProductCreate() {
// Tom Select instance // Tom Select instance
vendorSelectInstance: null, vendorSelectInstance: null,
// Active language tab
activeLanguage: 'en',
// Form data // Form data
form: { form: {
vendor_id: null, vendor_id: null,
title: '', // Translations by language
brand: '', translations: defaultTranslations(),
// Product identifiers
vendor_sku: '', vendor_sku: '',
brand: '',
gtin: '', gtin: '',
gtin_type: '',
// Pricing
price: null, price: null,
sale_price: null,
currency: 'EUR', currency: 'EUR',
tax_rate_percent: 17,
availability: '', availability: '',
// Image
primary_image_url: '',
// Status
is_active: true, is_active: true,
is_featured: false, is_featured: false,
is_digital: false, is_digital: false
description: ''
}, },
async init() { async init() {
@@ -111,6 +130,33 @@ function adminVendorProductCreate() {
adminVendorProductCreateLog.info('Vendor select initialized'); 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 * Create the product
*/ */
@@ -120,30 +166,54 @@ function adminVendorProductCreate() {
return; return;
} }
if (!this.form.title) { if (!this.form.translations.en.title?.trim()) {
Utils.showToast('Please enter a product title', 'error'); Utils.showToast('Please enter a product title (English)', 'error');
return; return;
} }
this.saving = true; this.saving = true;
try { 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 // Build create payload
const payload = { const payload = {
vendor_id: this.form.vendor_id, vendor_id: this.form.vendor_id,
title: this.form.title, translations: Object.keys(translations).length > 0 ? translations : null,
brand: this.form.brand || null, // Product identifiers
vendor_sku: this.form.vendor_sku || null, brand: this.form.brand?.trim() || null,
gtin: this.form.gtin || null, vendor_sku: this.form.vendor_sku?.trim() || null,
price: this.form.price ? parseFloat(this.form.price) : 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', 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, availability: this.form.availability || null,
// Image
primary_image_url: this.form.primary_image_url?.trim() || null,
// Status
is_active: this.form.is_active, is_active: this.form.is_active,
is_featured: this.form.is_featured, is_featured: this.form.is_featured,
is_digital: this.form.is_digital, is_digital: this.form.is_digital
description: this.form.description || null
}; };
adminVendorProductCreateLog.info('Creating product with payload:', payload);
const response = await apiClient.post('/admin/vendor-products', payload); const response = await apiClient.post('/admin/vendor-products', payload);
adminVendorProductCreateLog.info('Product created:', response.id); adminVendorProductCreateLog.info('Product created:', response.id);

View File

@@ -59,6 +59,7 @@ function adminVendorProductEdit() {
sale_price: null, sale_price: null,
currency: 'EUR', currency: 'EUR',
tax_rate_percent: 17, tax_rate_percent: 17,
availability: '',
// Image // Image
primary_image_url: '', primary_image_url: '',
// Product type & status // Product type & status
@@ -125,6 +126,7 @@ function adminVendorProductEdit() {
sale_price: response.sale_price || null, sale_price: response.sale_price || null,
currency: response.currency || 'EUR', currency: response.currency || 'EUR',
tax_rate_percent: response.tax_rate_percent ?? 17, tax_rate_percent: response.tax_rate_percent ?? 17,
availability: response.availability || '',
// Image // Image
primary_image_url: response.primary_image_url || '', primary_image_url: response.primary_image_url || '',
// Product type & status // Product type & status
@@ -238,9 +240,11 @@ function adminVendorProductEdit() {
currency: this.form.currency || null, currency: this.form.currency || null,
tax_rate_percent: this.form.tax_rate_percent !== null && this.form.tax_rate_percent !== '' tax_rate_percent: this.form.tax_rate_percent !== null && this.form.tax_rate_percent !== ''
? parseInt(this.form.tax_rate_percent) : null, ? parseInt(this.form.tax_rate_percent) : null,
availability: this.form.availability || null,
// Image // Image
primary_image_url: this.form.primary_image_url?.trim() || null, 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_active: this.form.is_active,
is_featured: this.form.is_featured, is_featured: this.form.is_featured,
// Optional supplier info // Optional supplier info

114
static/vendor/js/product-create.js vendored Normal file
View File

@@ -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');

View File

@@ -210,3 +210,287 @@ class TestAdminVendorProductsAPI:
) )
assert response.status_code == 403 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

View File

@@ -72,6 +72,8 @@ class TestProductModel:
assert product.is_active is True # Default assert product.is_active is True # Default
assert product.is_featured is False # 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.min_quantity == 1 # Default
assert product.display_order == 0 # Default assert product.display_order == 0 # Default
@@ -202,6 +204,82 @@ class TestProductModel:
assert info["price_source"] == 100.00 assert info["price_source"] == 100.00
assert info["brand_source"] == "SourceBrand" 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.unit
@pytest.mark.database @pytest.mark.database
@@ -209,17 +287,13 @@ class TestProductModel:
class TestProductInventoryProperties: class TestProductInventoryProperties:
"""Test Product inventory properties including digital product handling.""" """Test Product inventory properties including digital product handling."""
def test_physical_product_no_inventory_returns_zero( def test_physical_product_no_inventory_returns_zero(self, db, test_vendor):
self, db, test_vendor, test_marketplace_product
):
"""Test physical product with no inventory entries returns 0.""" """Test physical product with no inventory entries returns 0."""
# Ensure product is physical
test_marketplace_product.is_digital = False
db.commit()
product = Product( product = Product(
vendor_id=test_vendor.id, 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.add(product)
db.commit() db.commit()
@@ -230,18 +304,15 @@ class TestProductInventoryProperties:
assert product.total_inventory == 0 assert product.total_inventory == 0
assert product.available_inventory == 0 assert product.available_inventory == 0
def test_physical_product_with_inventory( def test_physical_product_with_inventory(self, db, test_vendor):
self, db, test_vendor, test_marketplace_product
):
"""Test physical product calculates inventory from entries.""" """Test physical product calculates inventory from entries."""
from models.database.inventory import Inventory from models.database.inventory import Inventory
test_marketplace_product.is_digital = False
db.commit()
product = Product( product = Product(
vendor_id=test_vendor.id, 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.add(product)
db.commit() db.commit()
@@ -274,16 +345,13 @@ class TestProductInventoryProperties:
assert product.total_inventory == 150 # 100 + 50 assert product.total_inventory == 150 # 100 + 50
assert product.available_inventory == 135 # (100-10) + (50-5) assert product.available_inventory == 135 # (100-10) + (50-5)
def test_digital_product_has_unlimited_inventory( def test_digital_product_has_unlimited_inventory(self, db, test_vendor):
self, db, test_vendor, test_marketplace_product
):
"""Test digital product returns unlimited inventory.""" """Test digital product returns unlimited inventory."""
test_marketplace_product.is_digital = True
db.commit()
product = Product( product = Product(
vendor_id=test_vendor.id, 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.add(product)
db.commit() db.commit()
@@ -294,18 +362,15 @@ class TestProductInventoryProperties:
assert product.total_inventory == Product.UNLIMITED_INVENTORY assert product.total_inventory == Product.UNLIMITED_INVENTORY
assert product.available_inventory == Product.UNLIMITED_INVENTORY assert product.available_inventory == Product.UNLIMITED_INVENTORY
def test_digital_product_ignores_inventory_entries( def test_digital_product_ignores_inventory_entries(self, db, test_vendor):
self, db, test_vendor, test_marketplace_product
):
"""Test digital product returns unlimited even with inventory entries.""" """Test digital product returns unlimited even with inventory entries."""
from models.database.inventory import Inventory from models.database.inventory import Inventory
test_marketplace_product.is_digital = True
db.commit()
product = Product( product = Product(
vendor_id=test_vendor.id, 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.add(product)
db.commit() db.commit()