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.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,

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
# ============================================================================

View File

@@ -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 {

View File

@@ -61,29 +61,89 @@
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 *</label>
<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.title"
required
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">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>
<input
@@ -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,18 +183,36 @@
<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>
<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"
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"
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>
<select
@@ -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>

View File

@@ -21,10 +21,14 @@
<!-- 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>
<!-- 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">
@@ -33,6 +37,18 @@
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,14 +225,16 @@
<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 -->
<!-- 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">
@@ -230,11 +250,11 @@
<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>
<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 || 'Unknown'">-</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>
@@ -242,6 +262,25 @@
</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>
<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>
</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">

View File

@@ -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

View File

@@ -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
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
- 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

View File

@@ -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,
}

View File

@@ -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

View File

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

View File

@@ -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
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
@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_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()