624 lines
19 KiB
Markdown
624 lines
19 KiB
Markdown
# Slice 3: Product Catalog Management
|
|
## Vendor Selects and Publishes Products
|
|
|
|
**Status**: 📋 NOT STARTED
|
|
**Timeline**: Week 3 (5 days)
|
|
**Prerequisites**: Slice 1 & 2 complete
|
|
|
|
## 🎯 Slice Objectives
|
|
|
|
Enable vendors to browse imported products, select which to publish, customize them, and manage their product catalog.
|
|
|
|
### User Stories
|
|
- As a Vendor Owner, I can browse imported products from staging
|
|
- As a Vendor Owner, I can select which products to publish to my catalog
|
|
- As a Vendor Owner, I can customize product information (pricing, descriptions)
|
|
- As a Vendor Owner, I can manage my published product catalog
|
|
- As a Vendor Owner, I can manually add products (not from marketplace)
|
|
- As a Vendor Owner, I can manage inventory for catalog products
|
|
|
|
### Success Criteria
|
|
- [ ] Vendor can browse all imported products in staging
|
|
- [ ] Vendor can filter/search staging products
|
|
- [ ] Vendor can select products for publishing
|
|
- [ ] Vendor can customize product details before/after publishing
|
|
- [ ] Published products appear in vendor catalog
|
|
- [ ] Vendor can manually create products
|
|
- [ ] Vendor can update product inventory
|
|
- [ ] Vendor can activate/deactivate products
|
|
- [ ] Product operations are properly isolated by vendor
|
|
|
|
## 📋 Backend Implementation
|
|
|
|
### Database Models
|
|
|
|
#### Product Model (`models/database/product.py`)
|
|
|
|
```python
|
|
class Product(Base, TimestampMixin):
|
|
"""
|
|
Vendor's published product catalog
|
|
These are customer-facing products
|
|
"""
|
|
__tablename__ = "products"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
|
|
# Basic information
|
|
sku = Column(String, nullable=False, index=True)
|
|
title = Column(String, nullable=False)
|
|
description = Column(Text)
|
|
short_description = Column(String(500))
|
|
|
|
# Pricing
|
|
price = Column(Numeric(10, 2), nullable=False)
|
|
compare_at_price = Column(Numeric(10, 2)) # Original price for discounts
|
|
cost_per_item = Column(Numeric(10, 2)) # For profit tracking
|
|
currency = Column(String(3), default="EUR")
|
|
|
|
# Categorization
|
|
category = Column(String)
|
|
subcategory = Column(String)
|
|
brand = Column(String)
|
|
tags = Column(JSON, default=list)
|
|
|
|
# SEO
|
|
slug = Column(String, unique=True, index=True)
|
|
meta_title = Column(String)
|
|
meta_description = Column(String)
|
|
|
|
# Images
|
|
featured_image = Column(String) # Main product image
|
|
image_urls = Column(JSON, default=list) # Additional images
|
|
|
|
# Status
|
|
is_active = Column(Boolean, default=True)
|
|
is_featured = Column(Boolean, default=False)
|
|
is_on_sale = Column(Boolean, default=False)
|
|
|
|
# Inventory (simple - detailed in Inventory model)
|
|
track_inventory = Column(Boolean, default=True)
|
|
stock_quantity = Column(Integer, default=0)
|
|
low_stock_threshold = Column(Integer, default=10)
|
|
|
|
# Marketplace source (if imported)
|
|
marketplace_product_id = Column(
|
|
Integer,
|
|
ForeignKey("marketplace_products.id"),
|
|
nullable=True
|
|
)
|
|
external_sku = Column(String, nullable=True) # Original marketplace SKU
|
|
|
|
# Additional data
|
|
attributes = Column(JSON, default=dict) # Custom attributes
|
|
weight = Column(Numeric(10, 2)) # For shipping
|
|
dimensions = Column(JSON) # {length, width, height}
|
|
|
|
# Relationships
|
|
vendor = relationship("Vendor", back_populates="products")
|
|
marketplace_source = relationship(
|
|
"MarketplaceProduct",
|
|
back_populates="published_product"
|
|
)
|
|
inventory_records = relationship("Inventory", back_populates="product")
|
|
order_items = relationship("OrderItem", back_populates="product")
|
|
|
|
# Indexes
|
|
__table_args__ = (
|
|
Index('ix_product_vendor_sku', 'vendor_id', 'sku'),
|
|
Index('ix_product_active', 'vendor_id', 'is_active'),
|
|
Index('ix_product_featured', 'vendor_id', 'is_featured'),
|
|
)
|
|
```
|
|
|
|
#### Inventory Model (`models/database/inventory.py`)
|
|
|
|
```python
|
|
class Inventory(Base, TimestampMixin):
|
|
"""Track product inventory by location"""
|
|
__tablename__ = "inventory"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False)
|
|
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
|
|
|
# Location
|
|
location_name = Column(String, default="Default") # Warehouse name
|
|
|
|
# Quantities
|
|
available_quantity = Column(Integer, default=0)
|
|
reserved_quantity = Column(Integer, default=0) # Pending orders
|
|
|
|
# Relationships
|
|
vendor = relationship("Vendor")
|
|
product = relationship("Product", back_populates="inventory_records")
|
|
movements = relationship("InventoryMovement", back_populates="inventory")
|
|
|
|
class InventoryMovement(Base, TimestampMixin):
|
|
"""Track inventory changes"""
|
|
__tablename__ = "inventory_movements"
|
|
|
|
id = Column(Integer, primary_key=True, index=True)
|
|
inventory_id = Column(Integer, ForeignKey("inventory.id"), nullable=False)
|
|
|
|
# Movement details
|
|
movement_type = Column(String) # 'received', 'sold', 'adjusted', 'returned'
|
|
quantity_change = Column(Integer) # Positive or negative
|
|
|
|
# Context
|
|
reference_type = Column(String, nullable=True) # 'order', 'import', 'manual'
|
|
reference_id = Column(Integer, nullable=True)
|
|
notes = Column(Text)
|
|
|
|
# Relationships
|
|
inventory = relationship("Inventory", back_populates="movements")
|
|
```
|
|
|
|
### Pydantic Schemas
|
|
|
|
#### Product Schemas (`models/schema/product.py`)
|
|
|
|
```python
|
|
class ProductCreate(BaseModel):
|
|
"""Create product from scratch"""
|
|
sku: str
|
|
title: str
|
|
description: Optional[str] = None
|
|
price: float = Field(..., gt=0)
|
|
compare_at_price: Optional[float] = None
|
|
cost_per_item: Optional[float] = None
|
|
category: Optional[str] = None
|
|
brand: Optional[str] = None
|
|
tags: List[str] = []
|
|
image_urls: List[str] = []
|
|
track_inventory: bool = True
|
|
stock_quantity: int = 0
|
|
is_active: bool = True
|
|
|
|
class ProductPublishFromMarketplace(BaseModel):
|
|
"""Publish product from marketplace staging"""
|
|
marketplace_product_id: int
|
|
custom_title: Optional[str] = None
|
|
custom_description: Optional[str] = None
|
|
custom_price: Optional[float] = None
|
|
custom_sku: Optional[str] = None
|
|
stock_quantity: int = 0
|
|
is_active: bool = True
|
|
|
|
class ProductUpdate(BaseModel):
|
|
"""Update existing product"""
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
short_description: Optional[str] = None
|
|
price: Optional[float] = None
|
|
compare_at_price: Optional[float] = None
|
|
category: Optional[str] = None
|
|
brand: Optional[str] = None
|
|
tags: Optional[List[str]] = None
|
|
image_urls: Optional[List[str]] = None
|
|
is_active: Optional[bool] = None
|
|
is_featured: Optional[bool] = None
|
|
stock_quantity: Optional[int] = None
|
|
|
|
class ProductResponse(BaseModel):
|
|
"""Product details"""
|
|
id: int
|
|
vendor_id: int
|
|
sku: str
|
|
title: str
|
|
description: Optional[str]
|
|
price: float
|
|
compare_at_price: Optional[float]
|
|
category: Optional[str]
|
|
brand: Optional[str]
|
|
tags: List[str]
|
|
featured_image: Optional[str]
|
|
image_urls: List[str]
|
|
is_active: bool
|
|
is_featured: bool
|
|
stock_quantity: int
|
|
marketplace_product_id: Optional[int]
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
```
|
|
|
|
### Service Layer
|
|
|
|
#### Product Service (`app/services/product_service.py`)
|
|
|
|
```python
|
|
class ProductService:
|
|
"""Handle product catalog operations"""
|
|
|
|
async def publish_from_marketplace(
|
|
self,
|
|
vendor_id: int,
|
|
publish_data: ProductPublishFromMarketplace,
|
|
db: Session
|
|
) -> Product:
|
|
"""Publish marketplace product to catalog"""
|
|
|
|
# Get marketplace product
|
|
mp_product = db.query(MarketplaceProduct).filter(
|
|
MarketplaceProduct.id == publish_data.marketplace_product_id,
|
|
MarketplaceProduct.vendor_id == vendor_id,
|
|
MarketplaceProduct.is_published == False
|
|
).first()
|
|
|
|
if not mp_product:
|
|
raise ProductNotFoundError("Marketplace product not found")
|
|
|
|
# Check if SKU already exists
|
|
sku = publish_data.custom_sku or mp_product.external_sku
|
|
existing = db.query(Product).filter(
|
|
Product.vendor_id == vendor_id,
|
|
Product.sku == sku
|
|
).first()
|
|
|
|
if existing:
|
|
raise ProductAlreadyExistsError(f"Product with SKU {sku} already exists")
|
|
|
|
# Create product
|
|
product = Product(
|
|
vendor_id=vendor_id,
|
|
sku=sku,
|
|
title=publish_data.custom_title or mp_product.title,
|
|
description=publish_data.custom_description or mp_product.description,
|
|
price=publish_data.custom_price or mp_product.price,
|
|
currency=mp_product.currency,
|
|
category=mp_product.category,
|
|
brand=mp_product.brand,
|
|
image_urls=mp_product.image_urls,
|
|
featured_image=mp_product.image_urls[0] if mp_product.image_urls else None,
|
|
marketplace_product_id=mp_product.id,
|
|
external_sku=mp_product.external_sku,
|
|
stock_quantity=publish_data.stock_quantity,
|
|
is_active=publish_data.is_active,
|
|
slug=self._generate_slug(publish_data.custom_title or mp_product.title)
|
|
)
|
|
|
|
db.add(product)
|
|
|
|
# Mark marketplace product as published
|
|
mp_product.is_published = True
|
|
mp_product.published_product_id = product.id
|
|
|
|
# Create initial inventory record
|
|
inventory = Inventory(
|
|
vendor_id=vendor_id,
|
|
product_id=product.id,
|
|
location_name="Default",
|
|
available_quantity=publish_data.stock_quantity,
|
|
reserved_quantity=0
|
|
)
|
|
db.add(inventory)
|
|
|
|
# Record inventory movement
|
|
if publish_data.stock_quantity > 0:
|
|
movement = InventoryMovement(
|
|
inventory_id=inventory.id,
|
|
movement_type="received",
|
|
quantity_change=publish_data.stock_quantity,
|
|
reference_type="import",
|
|
notes="Initial stock from marketplace import"
|
|
)
|
|
db.add(movement)
|
|
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
return product
|
|
|
|
async def create_product(
|
|
self,
|
|
vendor_id: int,
|
|
product_data: ProductCreate,
|
|
db: Session
|
|
) -> Product:
|
|
"""Create product manually"""
|
|
|
|
# Check SKU uniqueness
|
|
existing = db.query(Product).filter(
|
|
Product.vendor_id == vendor_id,
|
|
Product.sku == product_data.sku
|
|
).first()
|
|
|
|
if existing:
|
|
raise ProductAlreadyExistsError(f"SKU {product_data.sku} already exists")
|
|
|
|
product = Product(
|
|
vendor_id=vendor_id,
|
|
**product_data.dict(),
|
|
slug=self._generate_slug(product_data.title),
|
|
featured_image=product_data.image_urls[0] if product_data.image_urls else None
|
|
)
|
|
|
|
db.add(product)
|
|
|
|
# Create inventory
|
|
if product_data.track_inventory:
|
|
inventory = Inventory(
|
|
vendor_id=vendor_id,
|
|
product_id=product.id,
|
|
available_quantity=product_data.stock_quantity
|
|
)
|
|
db.add(inventory)
|
|
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
return product
|
|
|
|
def get_products(
|
|
self,
|
|
vendor_id: int,
|
|
db: Session,
|
|
is_active: Optional[bool] = None,
|
|
category: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
skip: int = 0,
|
|
limit: int = 100
|
|
) -> List[Product]:
|
|
"""Get vendor's product catalog"""
|
|
|
|
query = db.query(Product).filter(Product.vendor_id == vendor_id)
|
|
|
|
if is_active is not None:
|
|
query = query.filter(Product.is_active == is_active)
|
|
|
|
if category:
|
|
query = query.filter(Product.category == category)
|
|
|
|
if search:
|
|
query = query.filter(
|
|
or_(
|
|
Product.title.ilike(f"%{search}%"),
|
|
Product.sku.ilike(f"%{search}%"),
|
|
Product.brand.ilike(f"%{search}%")
|
|
)
|
|
)
|
|
|
|
return query.order_by(
|
|
Product.created_at.desc()
|
|
).offset(skip).limit(limit).all()
|
|
|
|
async def update_product(
|
|
self,
|
|
vendor_id: int,
|
|
product_id: int,
|
|
update_data: ProductUpdate,
|
|
db: Session
|
|
) -> Product:
|
|
"""Update product details"""
|
|
|
|
product = db.query(Product).filter(
|
|
Product.id == product_id,
|
|
Product.vendor_id == vendor_id
|
|
).first()
|
|
|
|
if not product:
|
|
raise ProductNotFoundError()
|
|
|
|
# Update fields
|
|
update_dict = update_data.dict(exclude_unset=True)
|
|
for field, value in update_dict.items():
|
|
setattr(product, field, value)
|
|
|
|
# Update stock if changed
|
|
if 'stock_quantity' in update_dict:
|
|
self._update_inventory(product, update_dict['stock_quantity'], db)
|
|
|
|
db.commit()
|
|
db.refresh(product)
|
|
|
|
return product
|
|
|
|
def _generate_slug(self, title: str) -> str:
|
|
"""Generate URL-friendly slug"""
|
|
import re
|
|
slug = title.lower()
|
|
slug = re.sub(r'[^a-z0-9]+', '-', slug)
|
|
slug = slug.strip('-')
|
|
return slug
|
|
|
|
def _update_inventory(
|
|
self,
|
|
product: Product,
|
|
new_quantity: int,
|
|
db: Session
|
|
):
|
|
"""Update product inventory"""
|
|
inventory = db.query(Inventory).filter(
|
|
Inventory.product_id == product.id
|
|
).first()
|
|
|
|
if inventory:
|
|
quantity_change = new_quantity - inventory.available_quantity
|
|
inventory.available_quantity = new_quantity
|
|
|
|
# Record movement
|
|
movement = InventoryMovement(
|
|
inventory_id=inventory.id,
|
|
movement_type="adjusted",
|
|
quantity_change=quantity_change,
|
|
reference_type="manual",
|
|
notes="Manual adjustment"
|
|
)
|
|
db.add(movement)
|
|
```
|
|
|
|
### API Endpoints
|
|
|
|
#### Product Endpoints (`app/api/v1/vendor/products.py`)
|
|
|
|
```python
|
|
@router.get("", response_model=List[ProductResponse])
|
|
async def get_products(
|
|
is_active: Optional[bool] = None,
|
|
category: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
current_user: User = Depends(get_current_vendor_user),
|
|
vendor: Vendor = Depends(get_current_vendor),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get vendor's product catalog"""
|
|
service = ProductService()
|
|
products = service.get_products(
|
|
vendor.id, db, is_active, category, search, skip, limit
|
|
)
|
|
return products
|
|
|
|
@router.post("", response_model=ProductResponse)
|
|
async def create_product(
|
|
product_data: ProductCreate,
|
|
current_user: User = Depends(get_current_vendor_user),
|
|
vendor: Vendor = Depends(get_current_vendor),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create product manually"""
|
|
service = ProductService()
|
|
product = await service.create_product(vendor.id, product_data, db)
|
|
return product
|
|
|
|
@router.post("/from-marketplace", response_model=ProductResponse)
|
|
async def publish_from_marketplace(
|
|
publish_data: ProductPublishFromMarketplace,
|
|
current_user: User = Depends(get_current_vendor_user),
|
|
vendor: Vendor = Depends(get_current_vendor),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Publish marketplace product to catalog"""
|
|
service = ProductService()
|
|
product = await service.publish_from_marketplace(
|
|
vendor.id, publish_data, db
|
|
)
|
|
return product
|
|
|
|
@router.get("/{product_id}", response_model=ProductResponse)
|
|
async def get_product(
|
|
product_id: int,
|
|
current_user: User = Depends(get_current_vendor_user),
|
|
vendor: Vendor = Depends(get_current_vendor),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get product details"""
|
|
product = db.query(Product).filter(
|
|
Product.id == product_id,
|
|
Product.vendor_id == vendor.id
|
|
).first()
|
|
|
|
if not product:
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
|
|
|
return product
|
|
|
|
@router.put("/{product_id}", response_model=ProductResponse)
|
|
async def update_product(
|
|
product_id: int,
|
|
update_data: ProductUpdate,
|
|
current_user: User = Depends(get_current_vendor_user),
|
|
vendor: Vendor = Depends(get_current_vendor),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update product"""
|
|
service = ProductService()
|
|
product = await service.update_product(vendor.id, product_id, update_data, db)
|
|
return product
|
|
|
|
@router.put("/{product_id}/toggle-active")
|
|
async def toggle_product_active(
|
|
product_id: int,
|
|
current_user: User = Depends(get_current_vendor_user),
|
|
vendor: Vendor = Depends(get_current_vendor),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Activate/deactivate product"""
|
|
product = db.query(Product).filter(
|
|
Product.id == product_id,
|
|
Product.vendor_id == vendor.id
|
|
).first()
|
|
|
|
if not product:
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
|
|
|
product.is_active = not product.is_active
|
|
db.commit()
|
|
|
|
return {"is_active": product.is_active}
|
|
|
|
@router.delete("/{product_id}")
|
|
async def delete_product(
|
|
product_id: int,
|
|
current_user: User = Depends(get_current_vendor_user),
|
|
vendor: Vendor = Depends(get_current_vendor),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Remove product from catalog"""
|
|
product = db.query(Product).filter(
|
|
Product.id == product_id,
|
|
Product.vendor_id == vendor.id
|
|
).first()
|
|
|
|
if not product:
|
|
raise HTTPException(status_code=404, detail="Product not found")
|
|
|
|
# Mark as inactive instead of deleting
|
|
product.is_active = False
|
|
db.commit()
|
|
|
|
return {"success": True}
|
|
```
|
|
|
|
## 🎨 Frontend Implementation
|
|
|
|
### Templates
|
|
|
|
#### Browse Marketplace Products (`templates/vendor/marketplace/browse.html`)
|
|
|
|
Uses Alpine.js for reactive filtering, selection, and bulk publishing.
|
|
|
|
#### Product Catalog (`templates/vendor/products/list.html`)
|
|
|
|
Product management interface with search, filters, and quick actions.
|
|
|
|
#### Product Edit (`templates/vendor/products/edit.html`)
|
|
|
|
Detailed product editing with image management and inventory tracking.
|
|
|
|
## ✅ Testing Checklist
|
|
|
|
### Backend Tests
|
|
- [ ] Product publishing from marketplace works
|
|
- [ ] Manual product creation works
|
|
- [ ] Product updates work correctly
|
|
- [ ] Inventory tracking is accurate
|
|
- [ ] SKU uniqueness is enforced
|
|
- [ ] Vendor isolation maintained
|
|
- [ ] Product search/filtering works
|
|
- [ ] Slug generation works correctly
|
|
|
|
### Frontend Tests
|
|
- [ ] Browse marketplace products
|
|
- [ ] Select multiple products for publishing
|
|
- [ ] Publish single product with customization
|
|
- [ ] View product catalog
|
|
- [ ] Edit product details
|
|
- [ ] Toggle product active status
|
|
- [ ] Delete/deactivate products
|
|
- [ ] Search and filter products
|
|
|
|
## ➡️ Next Steps
|
|
|
|
After completing Slice 3, move to **Slice 4: Customer Shopping Experience** to build the public-facing shop.
|
|
|
|
---
|
|
|
|
**Slice 3 Status**: 📋 Not Started
|
|
**Dependencies**: Slices 1 & 2 must be complete
|
|
**Estimated Duration**: 5 days |