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:
@@ -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,
|
||||
)
|
||||
47
alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py
Normal file
47
alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py
Normal 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")
|
||||
49
app/api/v1/vendor/products.py
vendored
49
app/api/v1/vendor/products.py
vendored
@@ -15,6 +15,7 @@ from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.product_service import product_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.schema.product import (
|
||||
ProductCreate,
|
||||
@@ -25,6 +26,10 @@ from models.schema.product import (
|
||||
ProductToggleResponse,
|
||||
ProductUpdate,
|
||||
)
|
||||
from models.schema.vendor_product import (
|
||||
VendorDirectProductCreate,
|
||||
VendorProductCreateResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/products")
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -106,6 +111,50 @@ def add_product_to_catalog(
|
||||
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)
|
||||
def update_product(
|
||||
product_id: int,
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -47,6 +47,7 @@ class VendorProductService:
|
||||
.options(
|
||||
joinedload(Product.vendor),
|
||||
joinedload(Product.marketplace_product),
|
||||
joinedload(Product.translations),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -246,26 +247,67 @@ class VendorProductService:
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
data: Product data dict
|
||||
data: Product data dict (includes translations dict for multiple languages)
|
||||
|
||||
Returns:
|
||||
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(
|
||||
vendor_id=data["vendor_id"],
|
||||
vendor_sku=data.get("vendor_sku"),
|
||||
brand=data.get("brand"),
|
||||
gtin=data.get("gtin"),
|
||||
price=data.get("price"),
|
||||
gtin_type=data.get("gtin_type"),
|
||||
currency=data.get("currency", "EUR"),
|
||||
tax_rate_percent=data.get("tax_rate_percent", 17),
|
||||
availability=data.get("availability"),
|
||||
primary_image_url=data.get("primary_image_url"),
|
||||
is_active=data.get("is_active", True),
|
||||
is_featured=data.get("is_featured", False),
|
||||
is_digital=data.get("is_digital", False),
|
||||
description=data.get("description"),
|
||||
is_digital=is_digital,
|
||||
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.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()
|
||||
|
||||
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
|
||||
|
||||
# Update other allowed fields
|
||||
# Note: is_digital is derived from marketplace_product, not directly updatable
|
||||
updatable_fields = [
|
||||
"vendor_sku",
|
||||
"brand",
|
||||
@@ -335,6 +376,8 @@ class VendorProductService:
|
||||
"gtin_type",
|
||||
"currency",
|
||||
"tax_rate_percent",
|
||||
"availability",
|
||||
"is_digital",
|
||||
"is_active",
|
||||
"is_featured",
|
||||
"primary_image_url",
|
||||
@@ -370,9 +413,22 @@ class VendorProductService:
|
||||
"""Build a product list item dict."""
|
||||
mp = product.marketplace_product
|
||||
|
||||
# Get title from marketplace product translations
|
||||
# Get title: prefer vendor translations, fallback to marketplace translations
|
||||
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)
|
||||
|
||||
return {
|
||||
|
||||
@@ -61,28 +61,88 @@
|
||||
Vendor
|
||||
</h3>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- Basic Information -->
|
||||
<!-- Product Information (Translations) -->
|
||||
<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
|
||||
Product Information <span class="text-red-500">*</span>
|
||||
</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>
|
||||
<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"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Vendor SKU</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
x-model="form.vendor_sku"
|
||||
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="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>
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
<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"
|
||||
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="4007817144145"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -119,17 +183,35 @@
|
||||
<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 class="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
{# noqa: FE-008 - Using raw number input for price with EUR prefix #}
|
||||
<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"
|
||||
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"
|
||||
/>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Price (incl. VAT)</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.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>
|
||||
<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>
|
||||
</select>
|
||||
</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>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-1">Availability</label>
|
||||
<select
|
||||
@@ -158,12 +252,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<!-- Image -->
|
||||
<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
|
||||
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>
|
||||
<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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -180,30 +313,9 @@
|
||||
/>
|
||||
<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 (HTML supported)"
|
||||
></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
|
||||
@@ -214,7 +326,7 @@
|
||||
</a>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<span x-show="saving" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
|
||||
@@ -21,17 +21,33 @@
|
||||
|
||||
<!-- Product Details -->
|
||||
<div x-show="!loading && product">
|
||||
<!-- Override Info Banner -->
|
||||
<div class="px-4 py-3 mb-6 bg-purple-50 dark:bg-purple-900/20 rounded-lg shadow-md">
|
||||
<!-- Info Banner - adapts based on whether product has marketplace source -->
|
||||
<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">
|
||||
<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>
|
||||
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Vendor Product Catalog Entry</p>
|
||||
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">
|
||||
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>
|
||||
<!-- Marketplace-sourced product -->
|
||||
<template x-if="product?.marketplace_product_id">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-purple-700 dark:text-purple-300">Vendor Product Catalog Entry</p>
|
||||
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">
|
||||
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>
|
||||
@@ -42,18 +58,20 @@
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<!-- View Source Product - only for marketplace-sourced products -->
|
||||
<a
|
||||
x-show="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">
|
||||
<span x-html="$icon('database', 'w-4 h-4 mr-2')"></span>
|
||||
View Source Product
|
||||
</a>
|
||||
<button
|
||||
@click="openEditModal()"
|
||||
<a
|
||||
: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">
|
||||
<span x-html="$icon('pencil', 'w-4 h-4 mr-2')"></span>
|
||||
Edit Overrides
|
||||
</button>
|
||||
<span x-text="product?.marketplace_product_id ? 'Edit Overrides' : 'Edit Product'"></span>
|
||||
</a>
|
||||
<button
|
||||
@click="toggleActive()"
|
||||
:class="product?.is_active
|
||||
@@ -194,7 +212,7 @@
|
||||
<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-4">
|
||||
<div class="grid gap-4" :class="product?.marketplace_product_id ? 'md:grid-cols-4' : 'md:grid-cols-3'">
|
||||
<div>
|
||||
<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>
|
||||
@@ -207,41 +225,62 @@
|
||||
<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>
|
||||
</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-sm font-mono text-gray-700 dark:text-gray-300" x-text="product?.source_sku || '-'">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source Information Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Source Information
|
||||
<!-- Source Information Card - only for marketplace-sourced 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">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<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>
|
||||
<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 || '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 class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<span x-html="$icon('plus-circle', 'w-5 h-5 text-blue-600 dark:text-blue-400')"></span>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Direct Creation</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">This product was created directly in the vendor's catalog</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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">
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<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-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 #}
|
||||
<div>
|
||||
<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>
|
||||
</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="">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>
|
||||
|
||||
@@ -253,14 +268,13 @@
|
||||
Product Type & Status
|
||||
</h3>
|
||||
<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
|
||||
type="checkbox"
|
||||
x-model="form.is_digital"
|
||||
disabled
|
||||
class="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 cursor-not-allowed"
|
||||
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 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 class="flex items-center">
|
||||
<input
|
||||
|
||||
22
app/templates/vendor/media.html
vendored
22
app/templates/vendor/media.html
vendored
@@ -44,7 +44,7 @@
|
||||
<!-- Images -->
|
||||
<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">
|
||||
<span x-html="$icon('image', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('photograph', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Images</p>
|
||||
@@ -55,7 +55,7 @@
|
||||
<!-- Videos -->
|
||||
<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">
|
||||
<span x-html="$icon('video', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('play', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Videos</p>
|
||||
@@ -66,7 +66,7 @@
|
||||
<!-- Documents -->
|
||||
<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">
|
||||
<span x-html="$icon('file-text', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Documents</p>
|
||||
@@ -128,7 +128,7 @@
|
||||
<!-- Empty State -->
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -163,14 +163,14 @@
|
||||
<!-- Video icon -->
|
||||
<template x-if="item.media_type === 'video'">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<!-- Document icon -->
|
||||
<template x-if="item.media_type === 'document'">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -251,7 +251,7 @@
|
||||
>
|
||||
|
||||
<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>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-2">Drag and drop files here, or</p>
|
||||
<button
|
||||
@@ -270,7 +270,7 @@
|
||||
<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-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="{
|
||||
'text-green-500': file.status === 'success',
|
||||
'text-red-500': file.status === 'error',
|
||||
@@ -325,7 +325,7 @@
|
||||
</template>
|
||||
<template x-if="selectedMedia?.media_type !== 'image'">
|
||||
<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>
|
||||
</template>
|
||||
</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"
|
||||
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>
|
||||
</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"
|
||||
: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
|
||||
</button>
|
||||
<div class="flex gap-3">
|
||||
|
||||
174
app/templates/vendor/product-create.html
vendored
Normal file
174
app/templates/vendor/product-create.html
vendored
Normal 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 %}
|
||||
291
docs/architecture/product-architecture.md
Normal file
291
docs/architecture/product-architecture.md
Normal file
@@ -0,0 +1,291 @@
|
||||
# Product Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The product management system uses an **independent copy pattern** where vendor products (`Product`) are fully independent entities that can optionally reference a marketplace source (`MarketplaceProduct`) for display purposes only.
|
||||
|
||||
## Core Principles
|
||||
|
||||
| Principle | Description |
|
||||
|-----------|-------------|
|
||||
| **Full Independence** | Vendor products have all their own fields - no inheritance or fallback to marketplace |
|
||||
| **Optional Source Reference** | `marketplace_product_id` is nullable - products can be created directly |
|
||||
| **No Reset Functionality** | No "reset to source" - products are independent from the moment of creation |
|
||||
| **Source for Display Only** | Source comparison info is read-only, used for "view original" display |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ MarketplaceProduct │
|
||||
│ (Central Repository - raw imported data from marketplaces) │
|
||||
│ │
|
||||
│ - marketplace_product_id (unique) │
|
||||
│ - gtin, mpn, sku │
|
||||
│ - brand, price_cents, sale_price_cents │
|
||||
│ - is_digital, product_type_enum │
|
||||
│ - translations (via MarketplaceProductTranslation) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
╳ No runtime dependency
|
||||
│
|
||||
│ Optional FK (for "view source" display only)
|
||||
│ marketplace_product_id (nullable)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Product │
|
||||
│ (Vendor's Independent Product - fully standalone) │
|
||||
│ │
|
||||
│ === IDENTIFIERS === │
|
||||
│ - vendor_id (required) │
|
||||
│ - vendor_sku │
|
||||
│ - gtin, gtin_type │
|
||||
│ │
|
||||
│ === PRODUCT TYPE (own columns) === │
|
||||
│ - is_digital (Boolean) │
|
||||
│ - product_type (String: physical, digital, service, subscription) │
|
||||
│ │
|
||||
│ === PRICING === │
|
||||
│ - price_cents, sale_price_cents │
|
||||
│ - currency, tax_rate_percent │
|
||||
│ │
|
||||
│ === CONTENT === │
|
||||
│ - brand, condition, availability │
|
||||
│ - primary_image_url, additional_images │
|
||||
│ - translations (via ProductTranslation) │
|
||||
│ │
|
||||
│ === STATUS === │
|
||||
│ - is_active, is_featured │
|
||||
│ │
|
||||
│ === SUPPLIER === │
|
||||
│ - supplier, cost_cents │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Product Creation Patterns
|
||||
|
||||
### 1. From Marketplace Source (Import)
|
||||
|
||||
When copying from a marketplace product:
|
||||
- All fields are **copied** at creation time
|
||||
- `marketplace_product_id` is set for source reference
|
||||
- No ongoing relationship - product is immediately independent
|
||||
|
||||
```python
|
||||
# Service copies all fields at import time
|
||||
product = Product(
|
||||
vendor_id=vendor.id,
|
||||
marketplace_product_id=marketplace_product.id, # Source reference
|
||||
# All fields copied - no inheritance
|
||||
brand=marketplace_product.brand,
|
||||
price=marketplace_product.price,
|
||||
is_digital=marketplace_product.is_digital,
|
||||
product_type=marketplace_product.product_type_enum,
|
||||
# ... all other fields
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Direct Creation (No Marketplace Source)
|
||||
|
||||
Vendors can create products directly without a marketplace source:
|
||||
|
||||
```python
|
||||
product = Product(
|
||||
vendor_id=vendor.id,
|
||||
marketplace_product_id=None, # No source
|
||||
vendor_sku="DIRECT_001",
|
||||
brand="MyBrand",
|
||||
price=29.99,
|
||||
is_digital=True,
|
||||
product_type="digital",
|
||||
is_active=True,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Fields
|
||||
|
||||
### Product Type Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `is_digital` | Boolean | `False` | Whether product is digital (no physical shipping) |
|
||||
| `product_type` | String(20) | `"physical"` | Product type: physical, digital, service, subscription |
|
||||
|
||||
These are **independent columns** on Product, not derived from MarketplaceProduct.
|
||||
|
||||
### Source Reference
|
||||
|
||||
| Field | Type | Nullable | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `marketplace_product_id` | Integer FK | **Yes** | Optional reference to source MarketplaceProduct |
|
||||
|
||||
---
|
||||
|
||||
## Inventory Handling
|
||||
|
||||
Digital and physical products have different inventory behavior:
|
||||
|
||||
```python
|
||||
@property
|
||||
def has_unlimited_inventory(self) -> bool:
|
||||
"""Digital products have unlimited inventory."""
|
||||
return self.is_digital
|
||||
|
||||
@property
|
||||
def total_inventory(self) -> int:
|
||||
"""Get total inventory across all locations."""
|
||||
if self.is_digital:
|
||||
return Product.UNLIMITED_INVENTORY # 999999
|
||||
return sum(inv.quantity for inv in self.inventory_entries)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source Comparison (Display Only)
|
||||
|
||||
For products with a marketplace source, we provide comparison info for display:
|
||||
|
||||
```python
|
||||
def get_source_comparison_info(self) -> dict:
|
||||
"""Get current values with source values for comparison.
|
||||
|
||||
Used for "view original source" display feature.
|
||||
"""
|
||||
mp = self.marketplace_product
|
||||
return {
|
||||
"price": self.price,
|
||||
"price_source": mp.price if mp else None,
|
||||
"brand": self.brand,
|
||||
"brand_source": mp.brand if mp else None,
|
||||
# ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
This is **read-only** - there's no mechanism to "reset" to source values.
|
||||
|
||||
---
|
||||
|
||||
## UI Behavior
|
||||
|
||||
### Detail Page
|
||||
|
||||
| Product Type | Source Info Card | Edit Button Text |
|
||||
|-------------|------------------|------------------|
|
||||
| Marketplace-sourced | Shows source info with "View Source" link | "Edit Overrides" |
|
||||
| Directly created | Shows "Direct Creation" badge | "Edit Product" |
|
||||
|
||||
### Info Banner
|
||||
|
||||
- **Marketplace-sourced**: Purple banner - "Vendor Product Catalog Entry"
|
||||
- **Directly created**: Blue banner - "Directly Created Product"
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Product Table Key Columns
|
||||
|
||||
```sql
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY,
|
||||
vendor_id INTEGER NOT NULL REFERENCES vendors(id),
|
||||
marketplace_product_id INTEGER REFERENCES marketplace_products(id), -- Nullable!
|
||||
|
||||
-- Product Type (independent columns)
|
||||
is_digital BOOLEAN DEFAULT FALSE,
|
||||
product_type VARCHAR(20) DEFAULT 'physical',
|
||||
|
||||
-- Identifiers
|
||||
vendor_sku VARCHAR,
|
||||
gtin VARCHAR,
|
||||
gtin_type VARCHAR(10),
|
||||
brand VARCHAR,
|
||||
|
||||
-- Pricing (in cents)
|
||||
price_cents INTEGER,
|
||||
sale_price_cents INTEGER,
|
||||
currency VARCHAR(3) DEFAULT 'EUR',
|
||||
tax_rate_percent INTEGER DEFAULT 17,
|
||||
availability VARCHAR,
|
||||
|
||||
-- Media
|
||||
primary_image_url VARCHAR,
|
||||
additional_images JSON,
|
||||
|
||||
-- Status
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_featured BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Timestamps
|
||||
created_at TIMESTAMP,
|
||||
updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Index for product type queries
|
||||
CREATE INDEX idx_product_is_digital ON products(is_digital);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration History
|
||||
|
||||
| Migration | Description |
|
||||
|-----------|-------------|
|
||||
| `x2c3d4e5f6g7` | Made `marketplace_product_id` nullable |
|
||||
| `y3d4e5f6g7h8` | Added `is_digital` and `product_type` columns to products |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Create Product (Admin)
|
||||
|
||||
```
|
||||
POST /api/v1/admin/vendor-products
|
||||
{
|
||||
"vendor_id": 1,
|
||||
"translations": {
|
||||
"en": {"title": "Product Name", "description": "..."},
|
||||
"fr": {"title": "Nom du produit", "description": "..."}
|
||||
},
|
||||
"vendor_sku": "SKU001",
|
||||
"brand": "BrandName",
|
||||
"price": 29.99,
|
||||
"is_digital": false,
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
### Update Product (Admin)
|
||||
|
||||
```
|
||||
PATCH /api/v1/admin/vendor-products/{id}
|
||||
{
|
||||
"is_digital": true,
|
||||
"price": 39.99,
|
||||
"translations": {
|
||||
"en": {"title": "Updated Name"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Key test scenarios:
|
||||
|
||||
1. **Direct Product Creation** - Create without marketplace source
|
||||
2. **Digital Product Inventory** - Verify unlimited inventory for digital
|
||||
3. **is_digital Column** - Verify it's an independent column, not derived
|
||||
4. **Source Comparison** - Verify read-only source info display
|
||||
|
||||
See:
|
||||
- `tests/unit/models/database/test_product.py`
|
||||
- `tests/integration/api/v1/admin/test_vendor_products.py`
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
114
static/vendor/js/product-create.js
vendored
Normal file
114
static/vendor/js/product-create.js
vendored
Normal 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');
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user