feat: add admin inventory management (Phase 1)

- Add admin API endpoints for inventory management
- Add inventory page with vendor selector and filtering
- Add admin schemas for cross-vendor inventory operations
- Support digital products with unlimited inventory
- Add integration tests for admin inventory API
- Add inventory management guide documentation

Mirrors vendor inventory functionality with admin-level access.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 21:05:12 +01:00
parent 0ab10128ae
commit 8d8d41808b
12 changed files with 2880 additions and 3 deletions

View File

@@ -32,11 +32,13 @@ from . import (
companies,
content_pages,
dashboard,
inventory,
letzshop,
logs,
marketplace,
monitoring,
notifications,
orders,
products,
settings,
tests,
@@ -98,7 +100,7 @@ router.include_router(dashboard.router, tags=["admin-dashboard"])
# ============================================================================
# Product Catalog
# Vendor Operations (Product Catalog, Inventory & Orders)
# ============================================================================
# Include marketplace product catalog management endpoints
@@ -107,6 +109,12 @@ router.include_router(products.router, tags=["admin-marketplace-products"])
# Include vendor product catalog management endpoints
router.include_router(vendor_products.router, tags=["admin-vendor-products"])
# Include inventory management endpoints
router.include_router(inventory.router, tags=["admin-inventory"])
# Include order management endpoints
router.include_router(orders.router, tags=["admin-orders"])
# ============================================================================
# Marketplace & Imports

View File

@@ -0,0 +1,286 @@
# app/api/v1/admin/inventory.py
"""
Admin inventory management endpoints.
Provides inventory management capabilities for administrators:
- View inventory across all vendors
- View vendor-specific inventory
- Set/adjust inventory on behalf of vendors
- Low stock alerts and reporting
Admin Context: Uses admin JWT authentication.
Vendor selection is passed as a request parameter.
"""
import logging
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.inventory_service import inventory_service
from models.database.user import User
from models.schema.inventory import (
AdminInventoryAdjust,
AdminInventoryCreate,
AdminInventoryListResponse,
AdminInventoryLocationsResponse,
AdminInventoryStats,
AdminLowStockItem,
AdminVendorsWithInventoryResponse,
InventoryAdjust,
InventoryCreate,
InventoryMessageResponse,
InventoryResponse,
InventoryUpdate,
ProductInventorySummary,
)
router = APIRouter(prefix="/inventory")
logger = logging.getLogger(__name__)
# ============================================================================
# List & Statistics Endpoints
# ============================================================================
@router.get("", response_model=AdminInventoryListResponse)
def get_all_inventory(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
vendor_id: int | None = Query(None, description="Filter by vendor"),
location: str | None = Query(None, description="Filter by location"),
low_stock: int | None = Query(None, ge=0, description="Filter items below threshold"),
search: str | None = Query(None, description="Search by product title or SKU"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Get inventory across all vendors with filtering.
Allows admins to view and filter inventory across the platform.
"""
return inventory_service.get_all_inventory_admin(
db=db,
skip=skip,
limit=limit,
vendor_id=vendor_id,
location=location,
low_stock=low_stock,
search=search,
)
@router.get("/stats", response_model=AdminInventoryStats)
def get_inventory_stats(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get platform-wide inventory statistics."""
return inventory_service.get_inventory_stats_admin(db)
@router.get("/low-stock", response_model=list[AdminLowStockItem])
def get_low_stock_items(
threshold: int = Query(10, ge=0, description="Stock threshold"),
vendor_id: int | None = Query(None, description="Filter by vendor"),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get items with low stock levels."""
return inventory_service.get_low_stock_items_admin(
db=db,
threshold=threshold,
vendor_id=vendor_id,
limit=limit,
)
@router.get("/vendors", response_model=AdminVendorsWithInventoryResponse)
def get_vendors_with_inventory(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get list of vendors that have inventory entries."""
return inventory_service.get_vendors_with_inventory_admin(db)
@router.get("/locations", response_model=AdminInventoryLocationsResponse)
def get_inventory_locations(
vendor_id: int | None = Query(None, description="Filter by vendor"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get list of unique inventory locations."""
return inventory_service.get_inventory_locations_admin(db, vendor_id)
# ============================================================================
# Vendor-Specific Endpoints
# ============================================================================
@router.get("/vendors/{vendor_id}", response_model=AdminInventoryListResponse)
def get_vendor_inventory(
vendor_id: int,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=500),
location: str | None = Query(None, description="Filter by location"),
low_stock: int | None = Query(None, ge=0, description="Filter items below threshold"),
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get inventory for a specific vendor."""
return inventory_service.get_vendor_inventory_admin(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
location=location,
low_stock=low_stock,
)
@router.get("/products/{product_id}", response_model=ProductInventorySummary)
def get_product_inventory(
product_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get inventory summary for a specific product across all locations."""
return inventory_service.get_product_inventory_admin(db, product_id)
# ============================================================================
# Inventory Modification Endpoints
# ============================================================================
@router.post("/set", response_model=InventoryResponse)
def set_inventory(
inventory_data: AdminInventoryCreate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Set exact inventory quantity for a product at a location.
Admin version - requires explicit vendor_id in request body.
"""
# Verify vendor exists
inventory_service.verify_vendor_exists(db, inventory_data.vendor_id)
# Convert to standard schema for service
service_data = InventoryCreate(
product_id=inventory_data.product_id,
location=inventory_data.location,
quantity=inventory_data.quantity,
)
result = inventory_service.set_inventory(
db=db,
vendor_id=inventory_data.vendor_id,
inventory_data=service_data,
)
logger.info(
f"Admin {current_admin.email} set inventory for product {inventory_data.product_id} "
f"at {inventory_data.location}: {inventory_data.quantity} units"
)
db.commit()
return result
@router.post("/adjust", response_model=InventoryResponse)
def adjust_inventory(
adjustment: AdminInventoryAdjust,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""
Adjust inventory by adding or removing quantity.
Positive quantity = add stock, negative = remove stock.
Admin version - requires explicit vendor_id in request body.
"""
# Verify vendor exists
inventory_service.verify_vendor_exists(db, adjustment.vendor_id)
# Convert to standard schema for service
service_data = InventoryAdjust(
product_id=adjustment.product_id,
location=adjustment.location,
quantity=adjustment.quantity,
)
result = inventory_service.adjust_inventory(
db=db,
vendor_id=adjustment.vendor_id,
inventory_data=service_data,
)
sign = "+" if adjustment.quantity >= 0 else ""
logger.info(
f"Admin {current_admin.email} adjusted inventory for product {adjustment.product_id} "
f"at {adjustment.location}: {sign}{adjustment.quantity} units"
f"{f' (reason: {adjustment.reason})' if adjustment.reason else ''}"
)
db.commit()
return result
@router.put("/{inventory_id}", response_model=InventoryResponse)
def update_inventory(
inventory_id: int,
inventory_update: InventoryUpdate,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Update inventory entry fields."""
# Get inventory to find vendor_id
inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id)
result = inventory_service.update_inventory(
db=db,
vendor_id=inventory.vendor_id,
inventory_id=inventory_id,
inventory_update=inventory_update,
)
logger.info(f"Admin {current_admin.email} updated inventory {inventory_id}")
db.commit()
return result
@router.delete("/{inventory_id}", response_model=InventoryMessageResponse)
def delete_inventory(
inventory_id: int,
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Delete inventory entry."""
# Get inventory to find vendor_id and log details
inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id)
vendor_id = inventory.vendor_id
product_id = inventory.product_id
location = inventory.location
inventory_service.delete_inventory(
db=db,
vendor_id=vendor_id,
inventory_id=inventory_id,
)
logger.info(
f"Admin {current_admin.email} deleted inventory {inventory_id} "
f"(product {product_id} at {location})"
)
db.commit()
return InventoryMessageResponse(message="Inventory deleted successfully")

View File

@@ -24,6 +24,8 @@ Routes:
- GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required)
- GET /users → User management page (auth required)
- GET /customers → Customer management page (auth required)
- GET /inventory → Inventory management page (auth required)
- GET /orders → Orders management page (auth required)
- GET /imports → Import history page (auth required)
- GET /marketplace-products → Marketplace products catalog (auth required)
- GET /vendor-products → Vendor products catalog (auth required)
@@ -474,6 +476,54 @@ async def admin_customers_page(
)
# ============================================================================
# INVENTORY MANAGEMENT ROUTES
# ============================================================================
@router.get("/inventory", response_class=HTMLResponse, include_in_schema=False)
async def admin_inventory_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render inventory management page.
Shows stock levels across all vendors with filtering and adjustment capabilities.
"""
return templates.TemplateResponse(
"admin/inventory.html",
{
"request": request,
"user": current_user,
},
)
# ============================================================================
# ORDER MANAGEMENT ROUTES
# ============================================================================
@router.get("/orders", response_class=HTMLResponse, include_in_schema=False)
async def admin_orders_page(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render orders management page.
Shows orders across all vendors with filtering and status management.
"""
return templates.TemplateResponse(
"admin/orders.html",
{
"request": request,
"user": current_user,
},
)
# ============================================================================
# IMPORT MANAGEMENT ROUTES
# ============================================================================

View File

@@ -2,6 +2,7 @@
import logging
from datetime import UTC, datetime
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.exceptions import (
@@ -11,10 +12,19 @@ from app.exceptions import (
InventoryValidationException,
ProductNotFoundException,
ValidationException,
VendorNotFoundException,
)
from models.database.inventory import Inventory
from models.database.product import Product
from models.database.vendor import Vendor
from models.schema.inventory import (
AdminInventoryItem,
AdminInventoryListResponse,
AdminInventoryLocationsResponse,
AdminInventoryStats,
AdminLowStockItem,
AdminVendorsWithInventoryResponse,
AdminVendorWithInventory,
InventoryAdjust,
InventoryCreate,
InventoryLocationResponse,
@@ -547,7 +557,325 @@ class InventoryService:
logger.error(f"Error deleting inventory: {str(e)}")
raise ValidationException("Failed to delete inventory")
# =========================================================================
# Admin Methods (cross-vendor operations)
# =========================================================================
def get_all_inventory_admin(
self,
db: Session,
skip: int = 0,
limit: int = 50,
vendor_id: int | None = None,
location: str | None = None,
low_stock: int | None = None,
search: str | None = None,
) -> AdminInventoryListResponse:
"""
Get inventory across all vendors with filtering (admin only).
Args:
db: Database session
skip: Pagination offset
limit: Pagination limit
vendor_id: Filter by vendor
location: Filter by location
low_stock: Filter items below threshold
search: Search by product title or SKU
Returns:
AdminInventoryListResponse
"""
query = db.query(Inventory).join(Product).join(Vendor)
# Apply filters
if vendor_id is not None:
query = query.filter(Inventory.vendor_id == vendor_id)
if location:
query = query.filter(Inventory.location.ilike(f"%{location}%"))
if low_stock is not None:
query = query.filter(Inventory.quantity <= low_stock)
if search:
from models.database.marketplace_product import MarketplaceProduct
from models.database.marketplace_product_translation import (
MarketplaceProductTranslation,
)
query = (
query.join(MarketplaceProduct)
.outerjoin(MarketplaceProductTranslation)
.filter(
(MarketplaceProductTranslation.title.ilike(f"%{search}%"))
| (Product.vendor_sku.ilike(f"%{search}%"))
)
)
# Get total count before pagination
total = query.count()
# Apply pagination
inventories = query.offset(skip).limit(limit).all()
# Build response with vendor/product info
items = []
for inv in inventories:
product = inv.product
vendor = inv.vendor
title = None
if product and product.marketplace_product:
title = product.marketplace_product.get_title()
items.append(
AdminInventoryItem(
id=inv.id,
product_id=inv.product_id,
vendor_id=inv.vendor_id,
vendor_name=vendor.name if vendor else None,
vendor_code=vendor.vendor_code if vendor else None,
product_title=title,
product_sku=product.vendor_sku if product else None,
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
gtin=inv.gtin,
created_at=inv.created_at,
updated_at=inv.updated_at,
)
)
return AdminInventoryListResponse(
inventories=items,
total=total,
skip=skip,
limit=limit,
vendor_filter=vendor_id,
location_filter=location,
)
def get_inventory_stats_admin(self, db: Session) -> AdminInventoryStats:
"""Get platform-wide inventory statistics (admin only)."""
# Total entries
total_entries = db.query(func.count(Inventory.id)).scalar() or 0
# Aggregate quantities
totals = db.query(
func.sum(Inventory.quantity).label("total_qty"),
func.sum(Inventory.reserved_quantity).label("total_reserved"),
).first()
total_quantity = totals.total_qty or 0
total_reserved = totals.total_reserved or 0
total_available = total_quantity - total_reserved
# Low stock count (default threshold: 10)
low_stock_count = (
db.query(func.count(Inventory.id))
.filter(Inventory.quantity <= 10)
.scalar()
or 0
)
# Vendors with inventory
vendors_with_inventory = (
db.query(func.count(func.distinct(Inventory.vendor_id))).scalar() or 0
)
# Unique locations
unique_locations = (
db.query(func.count(func.distinct(Inventory.location))).scalar() or 0
)
return AdminInventoryStats(
total_entries=total_entries,
total_quantity=total_quantity,
total_reserved=total_reserved,
total_available=total_available,
low_stock_count=low_stock_count,
vendors_with_inventory=vendors_with_inventory,
unique_locations=unique_locations,
)
def get_low_stock_items_admin(
self,
db: Session,
threshold: int = 10,
vendor_id: int | None = None,
limit: int = 50,
) -> list[AdminLowStockItem]:
"""Get items with low stock levels (admin only)."""
query = (
db.query(Inventory)
.join(Product)
.join(Vendor)
.filter(Inventory.quantity <= threshold)
)
if vendor_id is not None:
query = query.filter(Inventory.vendor_id == vendor_id)
# Order by quantity ascending (most critical first)
query = query.order_by(Inventory.quantity.asc())
inventories = query.limit(limit).all()
items = []
for inv in inventories:
product = inv.product
vendor = inv.vendor
title = None
if product and product.marketplace_product:
title = product.marketplace_product.get_title()
items.append(
AdminLowStockItem(
id=inv.id,
product_id=inv.product_id,
vendor_id=inv.vendor_id,
vendor_name=vendor.name if vendor else None,
product_title=title,
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
)
)
return items
def get_vendors_with_inventory_admin(
self, db: Session
) -> AdminVendorsWithInventoryResponse:
"""Get list of vendors that have inventory entries (admin only)."""
vendors = (
db.query(Vendor).join(Inventory).distinct().order_by(Vendor.name).all()
)
return AdminVendorsWithInventoryResponse(
vendors=[
AdminVendorWithInventory(
id=v.id, name=v.name, vendor_code=v.vendor_code
)
for v in vendors
]
)
def get_inventory_locations_admin(
self, db: Session, vendor_id: int | None = None
) -> AdminInventoryLocationsResponse:
"""Get list of unique inventory locations (admin only)."""
query = db.query(func.distinct(Inventory.location))
if vendor_id is not None:
query = query.filter(Inventory.vendor_id == vendor_id)
locations = [loc[0] for loc in query.all()]
return AdminInventoryLocationsResponse(locations=sorted(locations))
def get_vendor_inventory_admin(
self,
db: Session,
vendor_id: int,
skip: int = 0,
limit: int = 50,
location: str | None = None,
low_stock: int | None = None,
) -> AdminInventoryListResponse:
"""Get inventory for a specific vendor (admin only)."""
# Verify vendor exists
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(f"Vendor {vendor_id} not found")
# Use the existing method
inventories = self.get_vendor_inventory(
db=db,
vendor_id=vendor_id,
skip=skip,
limit=limit,
location=location,
low_stock_threshold=low_stock,
)
# Build response with product info
items = []
for inv in inventories:
product = inv.product
title = None
if product and product.marketplace_product:
title = product.marketplace_product.get_title()
items.append(
AdminInventoryItem(
id=inv.id,
product_id=inv.product_id,
vendor_id=inv.vendor_id,
vendor_name=vendor.name,
vendor_code=vendor.vendor_code,
product_title=title,
product_sku=product.vendor_sku if product else None,
location=inv.location,
quantity=inv.quantity,
reserved_quantity=inv.reserved_quantity,
available_quantity=inv.available_quantity,
gtin=inv.gtin,
created_at=inv.created_at,
updated_at=inv.updated_at,
)
)
# Get total count for pagination
total_query = db.query(func.count(Inventory.id)).filter(
Inventory.vendor_id == vendor_id
)
if location:
total_query = total_query.filter(Inventory.location.ilike(f"%{location}%"))
if low_stock is not None:
total_query = total_query.filter(Inventory.quantity <= low_stock)
total = total_query.scalar() or 0
return AdminInventoryListResponse(
inventories=items,
total=total,
skip=skip,
limit=limit,
vendor_filter=vendor_id,
location_filter=location,
)
def get_product_inventory_admin(
self, db: Session, product_id: int
) -> ProductInventorySummary:
"""Get inventory summary for a product (admin only - no vendor check)."""
product = db.query(Product).filter(Product.id == product_id).first()
if not product:
raise ProductNotFoundException(f"Product {product_id} not found")
# Use existing method with the product's vendor_id
return self.get_product_inventory(db, product.vendor_id, product_id)
def verify_vendor_exists(self, db: Session, vendor_id: int) -> Vendor:
"""Verify vendor exists and return it."""
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
if not vendor:
raise VendorNotFoundException(f"Vendor {vendor_id} not found")
return vendor
def get_inventory_by_id_admin(self, db: Session, inventory_id: int) -> Inventory:
"""Get inventory by ID (admin only - returns inventory with vendor_id)."""
inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first()
if not inventory:
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
return inventory
# =========================================================================
# Private helper methods
# =========================================================================
def _get_vendor_product(
self, db: Session, vendor_id: int, product_id: int
) -> Product:

View File

@@ -0,0 +1,440 @@
{# app/templates/admin/inventory.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/pagination.html' import pagination %}
{% from 'shared/macros/headers.html' import page_header %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/tables.html' import table_wrapper %}
{% from 'shared/macros/modals.html' import modal_simple %}
{% from 'shared/macros/inputs.html' import vendor_selector %}
{% block title %}Inventory{% endblock %}
{% block alpine_data %}adminInventory(){% endblock %}
{% block content %}
{{ page_header('Inventory', subtitle='Manage stock levels across all vendors') }}
{{ loading_state('Loading inventory...') }}
{{ error_state('Error loading inventory') }}
<!-- Stats Cards -->
<div x-show="!loading" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
<!-- Card: Total Entries -->
<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('archive', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Entries
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_entries || 0">
0
</p>
</div>
</div>
<!-- Card: Total Quantity -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
<span x-html="$icon('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Total Stock
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_quantity || 0)">
0
</p>
</div>
</div>
<!-- Card: Available Stock -->
<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('check-circle', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Available
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(stats.total_available || 0)">
0
</p>
</div>
</div>
<!-- Card: Low Stock Items -->
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
Low Stock
</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.low_stock_count || 0">
0
</p>
</div>
</div>
</div>
<!-- Search and Filters Bar -->
<div x-show="!loading" class="mb-6 p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<!-- Search Input -->
<div class="flex-1 max-w-xl">
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-3">
<span x-html="$icon('search', 'w-5 h-5 text-gray-400')"></span>
</span>
<input
type="text"
x-model="filters.search"
@input="debouncedSearch()"
placeholder="Search by product title or SKU..."
class="w-full pl-10 pr-4 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"
>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-3">
<!-- Vendor Filter (Tom Select) -->
{{ vendor_selector(
ref_name='vendorSelect',
id='inventory-vendor-select',
placeholder='Filter by vendor...',
width='w-64'
) }}
<!-- Location Filter -->
<select
x-model="filters.location"
@change="pagination.page = 1; loadInventory()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Locations</option>
<template x-for="loc in locations" :key="loc">
<option :value="loc" x-text="loc"></option>
</template>
</select>
<!-- Low Stock Filter -->
<select
x-model="filters.low_stock"
@change="pagination.page = 1; loadInventory()"
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:border-purple-400 focus:outline-none"
>
<option value="">All Stock Levels</option>
<option value="5">Low Stock (< 5)</option>
<option value="10">Low Stock (< 10)</option>
<option value="25">Low Stock (< 25)</option>
<option value="50">Low Stock (< 50)</option>
</select>
<!-- Refresh Button -->
<button
@click="refresh()"
class="flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none transition-colors"
title="Refresh inventory"
>
<span x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
</div>
</div>
</div>
<!-- Inventory Table with Pagination -->
<div x-show="!loading">
{% call table_wrapper() %}
<thead>
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
<th class="px-4 py-3">Product</th>
<th class="px-4 py-3">Vendor</th>
<th class="px-4 py-3">Location</th>
<th class="px-4 py-3 text-right">Quantity</th>
<th class="px-4 py-3 text-right">Reserved</th>
<th class="px-4 py-3 text-right">Available</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
<!-- Empty State -->
<template x-if="inventory.length === 0">
<tr>
<td colspan="8" class="px-4 py-8 text-center text-gray-600 dark:text-gray-400">
<div class="flex flex-col items-center">
<span x-html="$icon('archive', 'w-12 h-12 mb-2 text-gray-300')"></span>
<p class="font-medium">No inventory entries found</p>
<p class="text-xs mt-1" x-text="filters.search || filters.vendor_id || filters.location || filters.low_stock ? 'Try adjusting your filters' : 'Inventory will appear here when products have stock entries'"></p>
</div>
</td>
</tr>
</template>
<!-- Inventory Rows -->
<template x-for="item in inventory" :key="item.id">
<tr class="text-gray-700 dark:text-gray-400">
<!-- Product Info -->
<td class="px-4 py-3">
<div class="flex items-center">
<!-- Product Image -->
<div class="w-10 h-10 mr-3 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700 flex-shrink-0">
<template x-if="item.product_image">
<img :src="item.product_image" :alt="item.product_title" class="w-full h-full object-cover" loading="lazy" />
</template>
<template x-if="!item.product_image">
<div class="w-full h-full flex items-center justify-center">
<span x-html="$icon('photograph', 'w-5 h-5 text-gray-400')"></span>
</div>
</template>
</div>
<!-- Product Details -->
<div class="min-w-0">
<p class="font-semibold text-sm truncate max-w-[200px]" x-text="item.product_title || 'Untitled'"></p>
<template x-if="item.product_sku">
<p class="text-xs text-gray-400 font-mono">SKU: <span x-text="item.product_sku"></span></p>
</template>
<template x-if="item.gtin">
<p class="text-xs text-gray-400 font-mono">GTIN: <span x-text="item.gtin"></span></p>
</template>
</div>
</div>
</td>
<!-- Vendor Info -->
<td class="px-4 py-3 text-sm">
<p class="font-medium" x-text="item.vendor_name || 'Unknown'"></p>
</td>
<!-- Location -->
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300" x-text="item.location"></span>
</td>
<!-- Quantity -->
<td class="px-4 py-3 text-sm text-right font-mono">
<span x-text="formatNumber(item.quantity)"></span>
</td>
<!-- Reserved -->
<td class="px-4 py-3 text-sm text-right font-mono">
<span :class="item.reserved_quantity > 0 ? 'text-orange-600 dark:text-orange-400' : ''" x-text="formatNumber(item.reserved_quantity)"></span>
</td>
<!-- Available -->
<td class="px-4 py-3 text-sm text-right font-mono font-semibold">
<span :class="item.available_quantity <= 0 ? 'text-red-600 dark:text-red-400' : item.available_quantity < 10 ? 'text-orange-600 dark:text-orange-400' : 'text-green-600 dark:text-green-400'" x-text="formatNumber(item.available_quantity)"></span>
</td>
<!-- Status -->
<td class="px-4 py-3 text-sm">
<template x-if="item.available_quantity <= 0">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100">
Out of Stock
</span>
</template>
<template x-if="item.available_quantity > 0 && item.available_quantity < 10">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100">
Low Stock
</span>
</template>
<template x-if="item.available_quantity >= 10">
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100">
In Stock
</span>
</template>
</td>
<!-- Actions -->
<td class="px-4 py-3 text-sm">
<div class="flex items-center space-x-2">
<button
@click="openAdjustModal(item)"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-purple-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Adjust Stock"
>
<span x-html="$icon('adjustments', 'w-4 h-4')"></span>
</button>
<button
@click="openSetModal(item)"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-blue-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Set Quantity"
>
<span x-html="$icon('pencil', 'w-4 h-4')"></span>
</button>
<button
@click="confirmDelete(item)"
class="flex items-center justify-center px-2 py-1 text-xs font-medium leading-5 text-red-600 rounded-lg dark:text-red-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
title="Delete Entry"
>
<span x-html="$icon('delete', 'w-4 h-4')"></span>
</button>
</div>
</td>
</tr>
</template>
</tbody>
{% endcall %}
{{ pagination(show_condition="!loading && pagination.total > 0") }}
</div>
<!-- Adjust Stock Modal -->
{% call modal_simple('adjustStockModal', 'Adjust Stock', show_var='showAdjustModal', size='sm') %}
<div class="space-y-4">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-300" x-text="selectedItem?.product_title || 'Product'"></p>
<p class="text-xs">Location: <span x-text="selectedItem?.location || '-'"></span></p>
<p class="text-xs">Current Stock: <span x-text="formatNumber(selectedItem?.quantity || 0)"></span></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Adjustment Quantity
</label>
<div class="flex items-center gap-2">
<button
@click="adjustForm.quantity = Math.max(-999999, adjustForm.quantity - 1)"
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded"
>
<span x-html="$icon('minus', 'w-4 h-4')"></span>
</button>
{# noqa: FE-008 - Custom stepper with negative values for stock adjustments #}
<input
type="number"
x-model.number="adjustForm.quantity"
class="flex-1 px-3 py-2 text-sm text-center 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"
>
<button
@click="adjustForm.quantity = Math.min(999999, adjustForm.quantity + 1)"
class="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border border-gray-300 dark:border-gray-600 rounded"
>
<span x-html="$icon('plus', 'w-4 h-4')"></span>
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Positive = add stock, Negative = remove stock
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reason (optional)
</label>
<input
type="text"
x-model="adjustForm.reason"
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="e.g., Restocking from supplier"
>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400">
New quantity will be:
<span class="font-semibold" :class="(selectedItem?.quantity || 0) + adjustForm.quantity < 0 ? 'text-red-600' : 'text-green-600'" x-text="formatNumber(Math.max(0, (selectedItem?.quantity || 0) + adjustForm.quantity))"></span>
</p>
</div>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showAdjustModal = false"
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
</button>
<button
@click="executeAdjust()"
:disabled="saving || adjustForm.quantity === 0"
class="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-text="saving ? 'Saving...' : 'Adjust Stock'"></span>
</button>
</div>
</div>
{% endcall %}
<!-- Set Quantity Modal -->
{% call modal_simple('setQuantityModal', 'Set Quantity', show_var='showSetModal', size='sm') %}
<div class="space-y-4">
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium text-gray-700 dark:text-gray-300" x-text="selectedItem?.product_title || 'Product'"></p>
<p class="text-xs">Location: <span x-text="selectedItem?.location || '-'"></span></p>
<p class="text-xs">Current Stock: <span x-text="formatNumber(selectedItem?.quantity || 0)"></span></p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
New Quantity
</label>
<input
type="number"
x-model.number="setForm.quantity"
min="0"
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="Enter new quantity"
>
</div>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showSetModal = false"
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
</button>
<button
@click="executeSet()"
:disabled="saving || setForm.quantity < 0"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
<span x-text="saving ? 'Saving...' : 'Set Quantity'"></span>
</button>
</div>
</div>
{% endcall %}
<!-- Delete Confirmation Modal -->
{% call modal_simple('deleteModal', 'Delete Inventory Entry', show_var='showDeleteModal', size='sm') %}
<div class="space-y-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to delete this inventory entry?
</p>
<div class="text-sm">
<p class="font-medium text-gray-700 dark:text-gray-300" x-text="selectedItem?.product_title || 'Product'"></p>
<p class="text-xs text-gray-500">Location: <span x-text="selectedItem?.location || '-'"></span></p>
<p class="text-xs text-gray-500">Current Stock: <span x-text="formatNumber(selectedItem?.quantity || 0)"></span></p>
</div>
<p class="text-xs text-red-600 dark:text-red-400">
This action cannot be undone.
</p>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
@click="showDeleteModal = false"
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
</button>
<button
@click="executeDelete()"
:disabled="saving"
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
>
<span x-text="saving ? 'Deleting...' : 'Delete'"></span>
</button>
</div>
</div>
{% endcall %}
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/inventory.js') }}"></script>
{% endblock %}

View File

@@ -80,9 +80,9 @@
{{ menu_item('marketplace-products', '/admin/marketplace-products', 'database', 'Marketplace Products') }}
{{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Vendor Products') }}
{{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }}
{# Future items - uncomment when implemented:
{{ menu_item('inventory', '/admin/inventory', 'archive', 'Inventory') }}
{{ menu_item('orders', '/admin/orders', 'clipboard-list', 'Orders') }}
{# Future items - uncomment when implemented:
{{ menu_item('shipping', '/admin/shipping', 'truck', 'Shipping') }}
#}
{% endcall %}

View File

@@ -0,0 +1,366 @@
# Inventory Management
## Overview
The Wizamart platform provides comprehensive inventory management with support for:
- **Multi-location tracking** - Track stock across warehouses, stores, and storage bins
- **Reservation system** - Reserve items for pending orders
- **Digital products** - Automatic unlimited inventory for digital goods
- **Admin operations** - Manage inventory on behalf of vendors
---
## Key Concepts
### Storage Locations
Inventory is tracked at the **storage location level**. Each product can have stock in multiple locations:
```
Product: "Wireless Headphones"
├── WAREHOUSE_MAIN: 100 units (10 reserved)
├── WAREHOUSE_WEST: 50 units (0 reserved)
└── STORE_FRONT: 25 units (5 reserved)
Total: 175 units | Reserved: 15 | Available: 160
```
**Location naming:** Locations are text strings, normalized to UPPERCASE (e.g., `WAREHOUSE_A`, `STORE_01`).
### Inventory States
| Field | Description |
|-------|-------------|
| `quantity` | Total physical stock at location |
| `reserved_quantity` | Items reserved for pending orders |
| `available_quantity` | `quantity - reserved_quantity` (can be sold) |
### Product Types & Inventory
| Product Type | Inventory Behavior |
|--------------|-------------------|
| **Physical** | Requires inventory tracking, orders check available stock |
| **Digital** | **Unlimited inventory** - no stock constraints |
| **Service** | Treated as digital (unlimited) |
| **Subscription** | Treated as digital (unlimited) |
---
## Digital Products
Digital products have **unlimited inventory** by default. This means:
- Orders for digital products never fail due to "insufficient inventory"
- No need to create inventory entries for digital products
- The `available_inventory` property returns `999999` (effectively unlimited)
### How It Works
```python
# In Product model
@property
def has_unlimited_inventory(self) -> bool:
"""Digital products have unlimited inventory."""
return self.is_digital
@property
def available_inventory(self) -> int:
"""Calculate available inventory."""
if self.has_unlimited_inventory:
return 999999 # Unlimited
return sum(inv.available_quantity for inv in self.inventory_entries)
```
### Setting a Product as Digital
Digital products are identified by the `is_digital` flag on the `MarketplaceProduct`:
```python
marketplace_product.is_digital = True
marketplace_product.product_type_enum = "digital"
marketplace_product.digital_delivery_method = "license_key" # or "download", "email"
```
---
## Inventory Operations
### Set Inventory
Replace the exact quantity at a location:
```http
POST /api/v1/vendor/inventory/set
{
"product_id": 123,
"location": "WAREHOUSE_A",
"quantity": 100
}
```
### Adjust Inventory
Add or remove stock (positive = add, negative = remove):
```http
POST /api/v1/vendor/inventory/adjust
{
"product_id": 123,
"location": "WAREHOUSE_A",
"quantity": -10 // Remove 10 units
}
```
### Reserve Inventory
Mark items as reserved for an order:
```http
POST /api/v1/vendor/inventory/reserve
{
"product_id": 123,
"location": "WAREHOUSE_A",
"quantity": 5
}
```
### Release Reservation
Cancel a reservation (order cancelled):
```http
POST /api/v1/vendor/inventory/release
{
"product_id": 123,
"location": "WAREHOUSE_A",
"quantity": 5
}
```
### Fulfill Reservation
Complete an order (items shipped):
```http
POST /api/v1/vendor/inventory/fulfill
{
"product_id": 123,
"location": "WAREHOUSE_A",
"quantity": 5
}
```
This decreases both `quantity` and `reserved_quantity`.
---
## Reservation Workflow
```
┌─────────────────┐
│ Order Created │
└────────┬────────┘
┌─────────────────┐
│ Reserve Items │ reserved_quantity += order_qty
└────────┬────────┘
┌────┴────┐
│ │
▼ ▼
┌───────┐ ┌──────────┐
│Cancel │ │ Ship │
└───┬───┘ └────┬─────┘
│ │
▼ ▼
┌─────────┐ ┌──────────────┐
│ Release │ │ Fulfill │
│reserved │ │ quantity -= │
│ -= qty │ │ reserved -= │
└─────────┘ └──────────────┘
```
---
## Admin Inventory Management
Administrators can manage inventory on behalf of any vendor through the admin UI at `/admin/inventory` or via the API.
### Admin UI Features
The admin inventory page provides:
- **Overview Statistics** - Total entries, stock quantities, reserved items, and low stock alerts
- **Filtering** - Filter by vendor, location, and low stock threshold
- **Search** - Search by product title or SKU
- **Stock Adjustment** - Add or remove stock with optional reason tracking
- **Set Quantity** - Set exact stock quantity at any location
- **Delete Entries** - Remove inventory entries
### Admin API Endpoints
### List All Inventory
```http
GET /api/v1/admin/inventory
GET /api/v1/admin/inventory?vendor_id=1
GET /api/v1/admin/inventory?low_stock=10
```
### Get Inventory Statistics
```http
GET /api/v1/admin/inventory/stats
Response:
{
"total_entries": 150,
"total_quantity": 5000,
"total_reserved": 200,
"total_available": 4800,
"low_stock_count": 12,
"vendors_with_inventory": 5,
"unique_locations": 8
}
```
### Low Stock Alerts
```http
GET /api/v1/admin/inventory/low-stock?threshold=10
Response:
[
{
"product_id": 123,
"vendor_name": "TechStore",
"product_title": "USB Cable",
"location": "WAREHOUSE_A",
"quantity": 3,
"available_quantity": 2
}
]
```
### Set Inventory (Admin)
```http
POST /api/v1/admin/inventory/set
{
"vendor_id": 1,
"product_id": 123,
"location": "WAREHOUSE_A",
"quantity": 100
}
```
### Adjust Inventory (Admin)
```http
POST /api/v1/admin/inventory/adjust
{
"vendor_id": 1,
"product_id": 123,
"location": "WAREHOUSE_A",
"quantity": 25,
"reason": "Restocking from supplier"
}
```
---
## Database Schema
### Inventory Table
```sql
CREATE TABLE inventory (
id SERIAL PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id),
vendor_id INTEGER NOT NULL REFERENCES vendors(id),
location VARCHAR NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
reserved_quantity INTEGER DEFAULT 0,
gtin VARCHAR,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(product_id, location)
);
CREATE INDEX idx_inventory_vendor_product ON inventory(vendor_id, product_id);
CREATE INDEX idx_inventory_product_location ON inventory(product_id, location);
```
### Constraints
- **Unique constraint:** `(product_id, location)` - One entry per product/location
- **Foreign keys:** References `products` and `vendors` tables
- **Non-negative:** `quantity` and `reserved_quantity` must be >= 0
---
## Best Practices
### Physical Products
1. **Create inventory entries** before accepting orders
2. **Use meaningful location names** (e.g., `WAREHOUSE_MAIN`, `STORE_NYC`)
3. **Monitor low stock** using the admin dashboard or API
4. **Reserve on order creation** to prevent overselling
### Digital Products
1. **No inventory setup needed** - unlimited by default
2. **Optional:** Create entries for license key tracking
3. **Focus on fulfillment** - digital delivery mechanism
### Multi-Location
1. **Aggregate queries** use `Product.total_inventory` and `Product.available_inventory`
2. **Location-specific** operations use the Inventory model directly
3. **Transfers** between locations: adjust down at source, adjust up at destination
---
## API Reference
### Vendor Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/vendor/inventory/set` | Set exact quantity |
| POST | `/api/v1/vendor/inventory/adjust` | Add/remove quantity |
| POST | `/api/v1/vendor/inventory/reserve` | Reserve for order |
| POST | `/api/v1/vendor/inventory/release` | Cancel reservation |
| POST | `/api/v1/vendor/inventory/fulfill` | Complete order |
| GET | `/api/v1/vendor/inventory/product/{id}` | Product summary |
| GET | `/api/v1/vendor/inventory` | List with filters |
| PUT | `/api/v1/vendor/inventory/{id}` | Update entry |
| DELETE | `/api/v1/vendor/inventory/{id}` | Delete entry |
### Admin Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/admin/inventory` | List all (cross-vendor) |
| GET | `/api/v1/admin/inventory/stats` | Platform statistics |
| GET | `/api/v1/admin/inventory/low-stock` | Low stock alerts |
| GET | `/api/v1/admin/inventory/vendors` | Vendors with inventory |
| GET | `/api/v1/admin/inventory/locations` | Unique locations |
| GET | `/api/v1/admin/inventory/vendors/{id}` | Vendor inventory |
| GET | `/api/v1/admin/inventory/products/{id}` | Product summary |
| POST | `/api/v1/admin/inventory/set` | Set (requires vendor_id) |
| POST | `/api/v1/admin/inventory/adjust` | Adjust (requires vendor_id) |
| PUT | `/api/v1/admin/inventory/{id}` | Update entry |
| DELETE | `/api/v1/admin/inventory/{id}` | Delete entry |
---
## Related Documentation
- [Product Management](product-management.md)
- [Admin Inventory Migration Plan](../implementation/inventory-admin-migration.md)
- [Vendor Operations Expansion](../development/migration/vendor-operations-expansion.md)

View File

@@ -0,0 +1,370 @@
# Admin Inventory Management Migration Plan
## Overview
**Objective:** Add inventory management capabilities to the admin "Vendor Operations" section, allowing administrators to view and manage vendor inventory on their behalf.
**Status:** Phase 1 Complete
---
## Current State Analysis
### What Exists
The inventory system is **fully implemented at the vendor API level** with comprehensive functionality:
| Component | Status | Location |
|-----------|--------|----------|
| Database Model | ✅ Complete | `models/database/inventory.py` |
| Pydantic Schemas | ✅ Complete | `models/schema/inventory.py` |
| Service Layer | ✅ Complete | `app/services/inventory_service.py` |
| Vendor API | ✅ Complete | `app/api/v1/vendor/inventory.py` |
| Exceptions | ✅ Complete | `app/exceptions/inventory.py` |
| Unit Tests | ✅ Complete | `tests/unit/services/test_inventory_service.py` |
| Integration Tests | ✅ Complete | `tests/integration/api/v1/vendor/test_inventory.py` |
| Vendor UI | 🔲 Placeholder | `app/templates/vendor/inventory.html` |
| Admin API | 🔲 Not Started | - |
| Admin UI | 🔲 Not Started | - |
| Audit Trail | 🔲 Not Started | Logs only, no dedicated table |
### Storage/Location Architecture
The inventory system tracks stock at the **storage location level**:
```
Product A
├── WAREHOUSE_MAIN: 100 units (10 reserved)
├── WAREHOUSE_WEST: 50 units (0 reserved)
└── STORE_FRONT: 25 units (5 reserved)
Total: 175 units | Reserved: 15 | Available: 160
```
**Key design decisions:**
- One product can have inventory across multiple locations
- Unique constraint: `(product_id, location)` - one entry per product/location
- Locations are text strings, normalized to UPPERCASE
- Available quantity = `quantity - reserved_quantity`
### Existing Operations
| Operation | Description | Service Method |
|-----------|-------------|----------------|
| **Set** | Replace exact quantity at location | `set_inventory()` |
| **Adjust** | Add/remove quantity (positive/negative) | `adjust_inventory()` |
| **Reserve** | Mark items for pending order | `reserve_inventory()` |
| **Release** | Cancel reservation | `release_reservation()` |
| **Fulfill** | Complete order (reduces both qty & reserved) | `fulfill_reservation()` |
| **Get Product** | Summary across all locations | `get_product_inventory()` |
| **Get Vendor** | List with filters | `get_vendor_inventory()` |
| **Update** | Partial field update | `update_inventory()` |
| **Delete** | Remove inventory entry | `delete_inventory()` |
### Vendor API Endpoints
All endpoints at `/api/v1/vendor/inventory/*`:
| Method | Endpoint | Operation |
|--------|----------|-----------|
| POST | `/inventory/set` | Set exact quantity |
| POST | `/inventory/adjust` | Add/remove quantity |
| POST | `/inventory/reserve` | Reserve for order |
| POST | `/inventory/release` | Cancel reservation |
| POST | `/inventory/fulfill` | Complete order |
| GET | `/inventory/product/{id}` | Product summary |
| GET | `/inventory` | List with filters |
| PUT | `/inventory/{id}` | Update entry |
| DELETE | `/inventory/{id}` | Delete entry |
---
## Gap Analysis
### What's Missing for Admin Vendor Operations
1. **Admin API Endpoints** - ✅ Implemented in Phase 1
2. **Admin UI Page** - No inventory management interface in admin panel
3. **Vendor Selector** - Admin needs to select which vendor to manage
4. **Cross-Vendor View** - ✅ Implemented in Phase 1
5. **Audit Trail** - Only application logs, no queryable audit history
6. **Bulk Operations** - No bulk adjust/import capabilities
7. **Low Stock Alerts** - Basic filter exists, no alert configuration
### Digital Products - Infinite Inventory ✅
**Implementation Complete**
Digital products now have unlimited inventory by default:
```python
# In Product model (models/database/product.py)
UNLIMITED_INVENTORY = 999999
@property
def has_unlimited_inventory(self) -> bool:
"""Digital products have unlimited inventory."""
return self.is_digital
@property
def available_inventory(self) -> int:
"""Calculate available inventory (total - reserved).
Digital products return unlimited inventory.
"""
if self.has_unlimited_inventory:
return self.UNLIMITED_INVENTORY
return sum(inv.available_quantity for inv in self.inventory_entries)
```
**Behavior:**
- Physical products: Sum of inventory entries (0 if no entries)
- Digital products: Returns `999999` (unlimited) regardless of entries
- Orders for digital products never fail due to "insufficient inventory"
**Tests:** `tests/unit/models/database/test_product.py::TestProductInventoryProperties`
**Documentation:** [Inventory Management Guide](../guides/inventory-management.md)
---
## Migration Plan
### Phase 1: Admin API Endpoints
**Goal:** Expose inventory management to admin users with vendor selection
#### 1.1 New File: `app/api/v1/admin/inventory.py`
Admin endpoints that mirror vendor functionality with vendor_id as parameter:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/admin/inventory` | List all inventory (cross-vendor) |
| GET | `/admin/inventory/vendors/{vendor_id}` | Vendor-specific inventory |
| GET | `/admin/inventory/products/{product_id}` | Product inventory summary |
| POST | `/admin/inventory/set` | Set inventory (requires vendor_id) |
| POST | `/admin/inventory/adjust` | Adjust inventory |
| PUT | `/admin/inventory/{id}` | Update inventory entry |
| DELETE | `/admin/inventory/{id}` | Delete inventory entry |
| GET | `/admin/inventory/low-stock` | Low stock report |
#### 1.2 Schema Extensions
Add admin-specific request schemas in `models/schema/inventory.py`:
```python
class AdminInventoryCreate(InventoryCreate):
"""Admin version - requires explicit vendor_id."""
vendor_id: int = Field(..., description="Target vendor ID")
class AdminInventoryAdjust(InventoryAdjust):
"""Admin version - requires explicit vendor_id."""
vendor_id: int = Field(..., description="Target vendor ID")
class AdminInventoryListResponse(BaseModel):
"""Cross-vendor inventory list."""
inventories: list[InventoryResponse]
total: int
skip: int
limit: int
vendor_filter: int | None = None
```
#### 1.3 Service Layer Reuse
The existing `InventoryService` already accepts `vendor_id` as a parameter - **no service changes needed**. Admin endpoints simply pass the vendor_id from the request instead of from the JWT token.
### Phase 2: Admin UI
**Goal:** Create admin inventory management page
#### 2.1 New Files
| File | Description |
|------|-------------|
| `app/templates/admin/inventory.html` | Main inventory page |
| `static/admin/js/inventory.js` | Alpine.js controller |
#### 2.2 UI Features
1. **Vendor Selector Dropdown** - Filter by vendor (or show all)
2. **Inventory Table** - Product, Location, Quantity, Reserved, Available
3. **Search/Filter** - By product name, location, low stock
4. **Adjust Modal** - Quick add/remove with reason
5. **Pagination** - Handle large inventories
6. **Export** - CSV download (future)
#### 2.3 Page Layout
```
┌─────────────────────────────────────────────────────────┐
│ Inventory Management [Vendor: All ▼] │
├─────────────────────────────────────────────────────────┤
│ [Search...] [Location ▼] [Low Stock Only ☐] │
├─────────────────────────────────────────────────────────┤
│ Product │ Location │ Qty │ Reserved │ Available │
│──────────────┼─────────────┼─────┼──────────┼───────────│
│ Widget A │ WAREHOUSE_A │ 100 │ 10 │ 90 [⚙️] │
│ Widget A │ WAREHOUSE_B │ 50 │ 0 │ 50 [⚙️] │
│ Gadget B │ WAREHOUSE_A │ 25 │ 5 │ 20 [⚙️] │
├─────────────────────────────────────────────────────────┤
│ Showing 1-20 of 150 [< 1 2 3 ... >] │
└─────────────────────────────────────────────────────────┘
```
#### 2.4 Sidebar Integration
Add to `app/templates/admin/partials/sidebar.html`:
```html
<!-- Under Vendor Operations section -->
<li class="relative px-6 py-3">
<a href="/admin/inventory" ...>
<span class="inline-flex items-center">
<!-- inventory icon -->
<span class="ml-4">Inventory</span>
</span>
</a>
</li>
```
### Phase 3: Audit Trail (Optional Enhancement)
**Goal:** Track inventory changes with queryable history
#### 3.1 Database Migration
```sql
CREATE TABLE inventory_audit_log (
id SERIAL PRIMARY KEY,
inventory_id INTEGER REFERENCES inventory(id) ON DELETE SET NULL,
product_id INTEGER NOT NULL,
vendor_id INTEGER NOT NULL,
location VARCHAR(255) NOT NULL,
-- Change details
operation VARCHAR(50) NOT NULL, -- 'set', 'adjust', 'reserve', 'release', 'fulfill'
quantity_before INTEGER,
quantity_after INTEGER,
reserved_before INTEGER,
reserved_after INTEGER,
adjustment_amount INTEGER,
-- Context
reason VARCHAR(500),
performed_by INTEGER REFERENCES users(id),
performed_by_type VARCHAR(20) NOT NULL, -- 'vendor', 'admin', 'system'
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_audit_vendor ON inventory_audit_log(vendor_id);
CREATE INDEX idx_audit_product ON inventory_audit_log(product_id);
CREATE INDEX idx_audit_created ON inventory_audit_log(created_at);
```
#### 3.2 Service Enhancement
Add audit logging to `InventoryService` methods:
```python
def _log_audit(
self,
db: Session,
inventory: Inventory,
operation: str,
qty_before: int,
qty_after: int,
reserved_before: int,
reserved_after: int,
user_id: int | None,
user_type: str,
reason: str | None = None
) -> None:
"""Record inventory change in audit log."""
audit = InventoryAuditLog(
inventory_id=inventory.id,
product_id=inventory.product_id,
vendor_id=inventory.vendor_id,
location=inventory.location,
operation=operation,
quantity_before=qty_before,
quantity_after=qty_after,
reserved_before=reserved_before,
reserved_after=reserved_after,
adjustment_amount=qty_after - qty_before,
reason=reason,
performed_by=user_id,
performed_by_type=user_type,
)
db.add(audit)
```
---
## Implementation Checklist
### Phase 1: Admin API ✅
- [x] Create `app/api/v1/admin/inventory.py`
- [x] Add admin inventory schemas to `models/schema/inventory.py`
- [x] Register routes in `app/api/v1/admin/__init__.py`
- [x] Write integration tests `tests/integration/api/v1/admin/test_inventory.py`
### Phase 2: Admin UI
- [ ] Create `app/templates/admin/inventory.html`
- [ ] Create `static/admin/js/inventory.js`
- [ ] Add route in `app/routes/admin.py`
- [ ] Add sidebar menu item
- [ ] Update `static/admin/js/init-alpine.js` for page mapping
### Phase 3: Audit Trail (Optional)
- [ ] Create Alembic migration for `inventory_audit_log` table
- [ ] Create `models/database/inventory_audit_log.py`
- [ ] Update `InventoryService` with audit logging
- [ ] Add audit history endpoint
- [ ] Add audit history UI component
---
## Testing Strategy
### Unit Tests
- Admin schema validation tests
- Audit log creation tests (if implemented)
### Integration Tests
- Admin inventory endpoints with authentication
- Vendor isolation verification (admin can access any vendor)
- Audit trail creation on operations
### Manual Testing
- Verify vendor selector works correctly
- Test adjust modal workflow
- Confirm pagination with large datasets
---
## Rollback Plan
Each phase is independent:
1. **Phase 1 Rollback:** Remove admin inventory routes from `__init__.py`
2. **Phase 2 Rollback:** Remove sidebar link, delete template/JS files
3. **Phase 3 Rollback:** Run down migration to drop audit table
---
## Dependencies
- Existing `InventoryService` (no changes required)
- Admin authentication (`get_current_admin_api`)
- Vendor model for vendor selector dropdown
---
## Related Documentation
- [Vendor Operations Expansion Plan](../development/migration/vendor-operations-expansion.md)
- [Admin Integration Guide](../backend/admin-integration-guide.md)
- [Architecture Patterns](../architecture/architecture-patterns.md)

View File

@@ -96,3 +96,107 @@ class InventorySummaryResponse(BaseModel):
gtin: str
total_quantity: int
locations: list[InventoryLocationResponse]
# ============================================================================
# Admin Inventory Schemas
# ============================================================================
class AdminInventoryCreate(BaseModel):
"""Admin version of inventory create - requires explicit vendor_id."""
vendor_id: int = Field(..., description="Target vendor ID")
product_id: int = Field(..., description="Product ID in vendor catalog")
location: str = Field(..., description="Storage location")
quantity: int = Field(..., description="Exact inventory quantity", ge=0)
class AdminInventoryAdjust(BaseModel):
"""Admin version of inventory adjust - requires explicit vendor_id."""
vendor_id: int = Field(..., description="Target vendor ID")
product_id: int = Field(..., description="Product ID in vendor catalog")
location: str = Field(..., description="Storage location")
quantity: int = Field(
..., description="Quantity to add (positive) or remove (negative)"
)
reason: str | None = Field(None, description="Reason for adjustment")
class AdminInventoryItem(BaseModel):
"""Inventory item with vendor info for admin list view."""
model_config = ConfigDict(from_attributes=True)
id: int
product_id: int
vendor_id: int
vendor_name: str | None = None
vendor_code: str | None = None
product_title: str | None = None
product_sku: str | None = None
location: str
quantity: int
reserved_quantity: int
available_quantity: int
gtin: str | None = None
created_at: datetime
updated_at: datetime
class AdminInventoryListResponse(BaseModel):
"""Cross-vendor inventory list for admin."""
inventories: list[AdminInventoryItem]
total: int
skip: int
limit: int
vendor_filter: int | None = None
location_filter: str | None = None
class AdminInventoryStats(BaseModel):
"""Inventory statistics for admin dashboard."""
total_entries: int
total_quantity: int
total_reserved: int
total_available: int
low_stock_count: int
vendors_with_inventory: int
unique_locations: int
class AdminLowStockItem(BaseModel):
"""Low stock item for admin alerts."""
id: int
product_id: int
vendor_id: int
vendor_name: str | None = None
product_title: str | None = None
location: str
quantity: int
reserved_quantity: int
available_quantity: int
class AdminVendorWithInventory(BaseModel):
"""Vendor with inventory entries."""
id: int
name: str
vendor_code: str
class AdminVendorsWithInventoryResponse(BaseModel):
"""Response for vendors with inventory list."""
vendors: list[AdminVendorWithInventory]
class AdminInventoryLocationsResponse(BaseModel):
"""Response for unique inventory locations."""
locations: list[str]

View File

@@ -67,7 +67,9 @@ function data() {
'marketplace-products': 'vendorOps',
'vendor-products': 'vendorOps',
customers: 'vendorOps',
// Future: inventory, orders, shipping will map to 'vendorOps'
inventory: 'vendorOps',
orders: 'vendorOps',
// Future: shipping will map to 'vendorOps'
// Marketplace
'marketplace-letzshop': 'marketplace',
// Content Management

View File

@@ -0,0 +1,426 @@
// static/admin/js/inventory.js
/**
* Admin inventory management page logic
* View and manage stock levels across all vendors
*/
const adminInventoryLog = window.LogConfig.loggers.adminInventory ||
window.LogConfig.createLogger('adminInventory', false);
adminInventoryLog.info('Loading...');
function adminInventory() {
adminInventoryLog.info('adminInventory() called');
return {
// Inherit base layout state
...data(),
// Set page identifier
currentPage: 'inventory',
// Loading states
loading: true,
error: '',
saving: false,
// Inventory data
inventory: [],
stats: {
total_entries: 0,
total_quantity: 0,
total_reserved: 0,
total_available: 0,
low_stock_count: 0,
vendors_with_inventory: 0,
unique_locations: 0
},
// Filters
filters: {
search: '',
vendor_id: '',
location: '',
low_stock: ''
},
// Available locations for filter dropdown
locations: [],
// Vendor selector controller (Tom Select)
vendorSelector: null,
// Pagination
pagination: {
page: 1,
per_page: 50,
total: 0,
pages: 0
},
// Modal states
showAdjustModal: false,
showSetModal: false,
showDeleteModal: false,
selectedItem: null,
// Form data
adjustForm: {
quantity: 0,
reason: ''
},
setForm: {
quantity: 0
},
// Debounce timer
searchTimeout: null,
// Computed: Total pages
get totalPages() {
return this.pagination.pages;
},
// Computed: Start index for pagination display
get startIndex() {
if (this.pagination.total === 0) return 0;
return (this.pagination.page - 1) * this.pagination.per_page + 1;
},
// Computed: End index for pagination display
get endIndex() {
const end = this.pagination.page * this.pagination.per_page;
return end > this.pagination.total ? this.pagination.total : end;
},
// Computed: Page numbers for pagination
get pageNumbers() {
const pages = [];
const totalPages = this.totalPages;
const current = this.pagination.page;
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
if (current > 3) {
pages.push('...');
}
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current < totalPages - 2) {
pages.push('...');
}
pages.push(totalPages);
}
return pages;
},
async init() {
adminInventoryLog.info('Inventory init() called');
// Guard against multiple initialization
if (window._adminInventoryInitialized) {
adminInventoryLog.warn('Already initialized, skipping');
return;
}
window._adminInventoryInitialized = true;
// Initialize vendor selector (Tom Select)
this.$nextTick(() => {
this.initVendorSelector();
});
// Load data in parallel
await Promise.all([
this.loadStats(),
this.loadLocations(),
this.loadInventory()
]);
adminInventoryLog.info('Inventory initialization complete');
},
/**
* Initialize vendor selector with Tom Select
*/
initVendorSelector() {
if (!this.$refs.vendorSelect) {
adminInventoryLog.warn('Vendor select element not found');
return;
}
this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, {
placeholder: 'Filter by vendor...',
onSelect: (vendor) => {
adminInventoryLog.info('Vendor selected:', vendor);
this.filters.vendor_id = vendor.id;
this.pagination.page = 1;
this.loadLocations();
this.loadInventory();
},
onClear: () => {
adminInventoryLog.info('Vendor filter cleared');
this.filters.vendor_id = '';
this.pagination.page = 1;
this.loadLocations();
this.loadInventory();
}
});
},
/**
* Load inventory statistics
*/
async loadStats() {
try {
const response = await apiClient.get('/admin/inventory/stats');
this.stats = response;
adminInventoryLog.info('Loaded stats:', this.stats);
} catch (error) {
adminInventoryLog.error('Failed to load stats:', error);
}
},
/**
* Load available locations for filter
*/
async loadLocations() {
try {
const params = this.filters.vendor_id ? `?vendor_id=${this.filters.vendor_id}` : '';
const response = await apiClient.get(`/admin/inventory/locations${params}`);
this.locations = response.locations || [];
adminInventoryLog.info('Loaded locations:', this.locations.length);
} catch (error) {
adminInventoryLog.error('Failed to load locations:', error);
}
},
/**
* Load inventory with filtering and pagination
*/
async loadInventory() {
this.loading = true;
this.error = '';
try {
const params = new URLSearchParams({
skip: (this.pagination.page - 1) * this.pagination.per_page,
limit: this.pagination.per_page
});
// Add filters
if (this.filters.search) {
params.append('search', this.filters.search);
}
if (this.filters.vendor_id) {
params.append('vendor_id', this.filters.vendor_id);
}
if (this.filters.location) {
params.append('location', this.filters.location);
}
if (this.filters.low_stock) {
params.append('low_stock', this.filters.low_stock);
}
const response = await apiClient.get(`/admin/inventory?${params.toString()}`);
this.inventory = response.items || [];
this.pagination.total = response.total || 0;
this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page);
adminInventoryLog.info('Loaded inventory:', this.inventory.length, 'of', this.pagination.total);
} catch (error) {
adminInventoryLog.error('Failed to load inventory:', error);
this.error = error.message || 'Failed to load inventory';
} finally {
this.loading = false;
}
},
/**
* Debounced search handler
*/
debouncedSearch() {
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.pagination.page = 1;
this.loadInventory();
}, 300);
},
/**
* Refresh inventory list
*/
async refresh() {
await Promise.all([
this.loadStats(),
this.loadLocations(),
this.loadInventory()
]);
},
/**
* Open adjust stock modal
*/
openAdjustModal(item) {
this.selectedItem = item;
this.adjustForm = {
quantity: 0,
reason: ''
};
this.showAdjustModal = true;
},
/**
* Open set quantity modal
*/
openSetModal(item) {
this.selectedItem = item;
this.setForm = {
quantity: item.quantity
};
this.showSetModal = true;
},
/**
* Confirm delete
*/
confirmDelete(item) {
this.selectedItem = item;
this.showDeleteModal = true;
},
/**
* Execute stock adjustment
*/
async executeAdjust() {
if (!this.selectedItem || this.adjustForm.quantity === 0) return;
this.saving = true;
try {
await apiClient.post('/admin/inventory/adjust', {
vendor_id: this.selectedItem.vendor_id,
product_id: this.selectedItem.product_id,
location: this.selectedItem.location,
quantity: this.adjustForm.quantity,
reason: this.adjustForm.reason || null
});
adminInventoryLog.info('Adjusted inventory:', this.selectedItem.id);
this.showAdjustModal = false;
this.selectedItem = null;
Utils.showToast('Stock adjusted successfully.', 'success');
await this.refresh();
} catch (error) {
adminInventoryLog.error('Failed to adjust inventory:', error);
Utils.showToast(error.message || 'Failed to adjust stock.', 'error');
} finally {
this.saving = false;
}
},
/**
* Execute set quantity
*/
async executeSet() {
if (!this.selectedItem || this.setForm.quantity < 0) return;
this.saving = true;
try {
await apiClient.post('/admin/inventory/set', {
vendor_id: this.selectedItem.vendor_id,
product_id: this.selectedItem.product_id,
location: this.selectedItem.location,
quantity: this.setForm.quantity
});
adminInventoryLog.info('Set inventory quantity:', this.selectedItem.id);
this.showSetModal = false;
this.selectedItem = null;
Utils.showToast('Quantity set successfully.', 'success');
await this.refresh();
} catch (error) {
adminInventoryLog.error('Failed to set inventory:', error);
Utils.showToast(error.message || 'Failed to set quantity.', 'error');
} finally {
this.saving = false;
}
},
/**
* Execute delete
*/
async executeDelete() {
if (!this.selectedItem) return;
this.saving = true;
try {
await apiClient.delete(`/admin/inventory/${this.selectedItem.id}`);
adminInventoryLog.info('Deleted inventory:', this.selectedItem.id);
this.showDeleteModal = false;
this.selectedItem = null;
Utils.showToast('Inventory entry deleted.', 'success');
await this.refresh();
} catch (error) {
adminInventoryLog.error('Failed to delete inventory:', error);
Utils.showToast(error.message || 'Failed to delete entry.', 'error');
} finally {
this.saving = false;
}
},
/**
* Format number with locale
*/
formatNumber(num) {
if (num === null || num === undefined) return '0';
return new Intl.NumberFormat('en-US').format(num);
},
/**
* Pagination: Previous page
*/
previousPage() {
if (this.pagination.page > 1) {
this.pagination.page--;
this.loadInventory();
}
},
/**
* Pagination: Next page
*/
nextPage() {
if (this.pagination.page < this.totalPages) {
this.pagination.page++;
this.loadInventory();
}
},
/**
* Pagination: Go to specific page
*/
goToPage(pageNum) {
if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) {
this.pagination.page = pageNum;
this.loadInventory();
}
}
};
}

View File

@@ -0,0 +1,497 @@
# tests/integration/api/v1/admin/test_inventory.py
"""
Integration tests for admin inventory management endpoints.
Tests the /api/v1/admin/inventory/* endpoints.
All endpoints require admin JWT authentication.
"""
import pytest
@pytest.mark.integration
@pytest.mark.api
@pytest.mark.admin
@pytest.mark.inventory
class TestAdminInventoryAPI:
"""Tests for admin inventory management endpoints."""
# ========================================================================
# List & Statistics Tests
# ========================================================================
def test_get_all_inventory_admin(
self, client, admin_headers, test_inventory, test_vendor
):
"""Test admin getting all inventory."""
response = client.get("/api/v1/admin/inventory", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert "inventories" in data
assert "total" in data
assert "skip" in data
assert "limit" in data
assert data["total"] >= 1
assert len(data["inventories"]) >= 1
# Check that test inventory is in the response
inventory_ids = [i["id"] for i in data["inventories"]]
assert test_inventory.id in inventory_ids
def test_get_all_inventory_non_admin(self, client, auth_headers):
"""Test non-admin trying to access inventory endpoint."""
response = client.get("/api/v1/admin/inventory", headers=auth_headers)
assert response.status_code == 403
data = response.json()
assert data["error_code"] == "ADMIN_REQUIRED"
def test_get_all_inventory_with_vendor_filter(
self, client, admin_headers, test_inventory, test_vendor
):
"""Test admin filtering inventory by vendor."""
response = client.get(
"/api/v1/admin/inventory",
params={"vendor_id": test_vendor.id},
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert data["vendor_filter"] == test_vendor.id
# All inventory should be from the filtered vendor
for item in data["inventories"]:
assert item["vendor_id"] == test_vendor.id
def test_get_all_inventory_with_location_filter(
self, client, admin_headers, test_inventory
):
"""Test admin filtering inventory by location."""
location = test_inventory.location[:5] # Partial match
response = client.get(
"/api/v1/admin/inventory",
params={"location": location},
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
# All items should have matching location
for item in data["inventories"]:
assert location.upper() in item["location"].upper()
def test_get_all_inventory_with_low_stock_filter(
self, client, admin_headers, test_inventory, db
):
"""Test admin filtering inventory by low stock threshold."""
# Set test inventory to low stock
test_inventory.quantity = 5
db.commit()
response = client.get(
"/api/v1/admin/inventory",
params={"low_stock": 10},
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
# All items should have quantity <= threshold
for item in data["inventories"]:
assert item["quantity"] <= 10
def test_get_all_inventory_pagination(
self, client, admin_headers, test_inventory
):
"""Test admin inventory pagination."""
response = client.get(
"/api/v1/admin/inventory",
params={"skip": 0, "limit": 10},
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["skip"] == 0
assert data["limit"] == 10
def test_get_inventory_stats_admin(
self, client, admin_headers, test_inventory
):
"""Test admin getting inventory statistics."""
response = client.get(
"/api/v1/admin/inventory/stats", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert "total_entries" in data
assert "total_quantity" in data
assert "total_reserved" in data
assert "total_available" in data
assert "low_stock_count" in data
assert "vendors_with_inventory" in data
assert "unique_locations" in data
assert data["total_entries"] >= 1
def test_get_inventory_stats_non_admin(self, client, auth_headers):
"""Test non-admin trying to access inventory stats."""
response = client.get(
"/api/v1/admin/inventory/stats", headers=auth_headers
)
assert response.status_code == 403
def test_get_low_stock_items_admin(
self, client, admin_headers, test_inventory, db
):
"""Test admin getting low stock items."""
# Set test inventory to low stock
test_inventory.quantity = 3
db.commit()
response = client.get(
"/api/v1/admin/inventory/low-stock",
params={"threshold": 10},
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
# All items should have quantity <= threshold
for item in data:
assert item["quantity"] <= 10
def test_get_vendors_with_inventory_admin(
self, client, admin_headers, test_inventory, test_vendor
):
"""Test admin getting vendors with inventory."""
response = client.get(
"/api/v1/admin/inventory/vendors", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert "vendors" in data
assert isinstance(data["vendors"], list)
assert len(data["vendors"]) >= 1
# Check that test_vendor is in the list
vendor_ids = [v["id"] for v in data["vendors"]]
assert test_vendor.id in vendor_ids
def test_get_inventory_locations_admin(
self, client, admin_headers, test_inventory
):
"""Test admin getting inventory locations."""
response = client.get(
"/api/v1/admin/inventory/locations", headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert "locations" in data
assert isinstance(data["locations"], list)
assert len(data["locations"]) >= 1
assert test_inventory.location in data["locations"]
# ========================================================================
# Vendor-Specific Tests
# ========================================================================
def test_get_vendor_inventory_admin(
self, client, admin_headers, test_inventory, test_vendor
):
"""Test admin getting vendor-specific inventory."""
response = client.get(
f"/api/v1/admin/inventory/vendors/{test_vendor.id}",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "inventories" in data
assert "total" in data
assert data["vendor_filter"] == test_vendor.id
assert data["total"] >= 1
# All inventory should be from this vendor
for item in data["inventories"]:
assert item["vendor_id"] == test_vendor.id
def test_get_vendor_inventory_not_found(self, client, admin_headers):
"""Test admin getting inventory for non-existent vendor."""
response = client.get(
"/api/v1/admin/inventory/vendors/99999",
headers=admin_headers,
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "VENDOR_NOT_FOUND"
def test_get_product_inventory_admin(
self, client, admin_headers, test_inventory, test_product
):
"""Test admin getting product inventory summary."""
response = client.get(
f"/api/v1/admin/inventory/products/{test_product.id}",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert data["product_id"] == test_product.id
assert "total_quantity" in data
assert "total_reserved" in data
assert "total_available" in data
assert "locations" in data
def test_get_product_inventory_not_found(self, client, admin_headers):
"""Test admin getting inventory for non-existent product."""
response = client.get(
"/api/v1/admin/inventory/products/99999",
headers=admin_headers,
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "PRODUCT_NOT_FOUND"
# ========================================================================
# Inventory Modification Tests
# ========================================================================
def test_set_inventory_admin(
self, client, admin_headers, test_product, test_vendor
):
"""Test admin setting inventory for a product."""
inventory_data = {
"vendor_id": test_vendor.id,
"product_id": test_product.id,
"location": "ADMIN_TEST_WAREHOUSE",
"quantity": 150,
}
response = client.post(
"/api/v1/admin/inventory/set",
headers=admin_headers,
json=inventory_data,
)
assert response.status_code == 200, f"Failed: {response.json()}"
data = response.json()
assert data["product_id"] == test_product.id
assert data["vendor_id"] == test_vendor.id
assert data["quantity"] == 150
assert data["location"] == "ADMIN_TEST_WAREHOUSE"
def test_set_inventory_non_admin(
self, client, auth_headers, test_product, test_vendor
):
"""Test non-admin trying to set inventory."""
inventory_data = {
"vendor_id": test_vendor.id,
"product_id": test_product.id,
"location": "WAREHOUSE_A",
"quantity": 100,
}
response = client.post(
"/api/v1/admin/inventory/set",
headers=auth_headers,
json=inventory_data,
)
assert response.status_code == 403
def test_set_inventory_vendor_not_found(
self, client, admin_headers, test_product
):
"""Test admin setting inventory for non-existent vendor."""
inventory_data = {
"vendor_id": 99999,
"product_id": test_product.id,
"location": "WAREHOUSE_A",
"quantity": 100,
}
response = client.post(
"/api/v1/admin/inventory/set",
headers=admin_headers,
json=inventory_data,
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "VENDOR_NOT_FOUND"
def test_adjust_inventory_add_admin(
self, client, admin_headers, test_inventory, test_vendor, test_product
):
"""Test admin adding to inventory."""
original_qty = test_inventory.quantity
adjust_data = {
"vendor_id": test_vendor.id,
"product_id": test_product.id,
"location": test_inventory.location,
"quantity": 25,
"reason": "Admin restocking",
}
response = client.post(
"/api/v1/admin/inventory/adjust",
headers=admin_headers,
json=adjust_data,
)
assert response.status_code == 200
data = response.json()
assert data["quantity"] == original_qty + 25
def test_adjust_inventory_remove_admin(
self, client, admin_headers, test_inventory, test_vendor, test_product, db
):
"""Test admin removing from inventory."""
# Ensure we have enough inventory
test_inventory.quantity = 100
db.commit()
adjust_data = {
"vendor_id": test_vendor.id,
"product_id": test_product.id,
"location": test_inventory.location,
"quantity": -10,
"reason": "Admin adjustment",
}
response = client.post(
"/api/v1/admin/inventory/adjust",
headers=admin_headers,
json=adjust_data,
)
assert response.status_code == 200
data = response.json()
assert data["quantity"] == 90
def test_adjust_inventory_insufficient(
self, client, admin_headers, test_inventory, test_vendor, test_product, db
):
"""Test admin trying to remove more than available."""
# Set low inventory
test_inventory.quantity = 5
db.commit()
adjust_data = {
"vendor_id": test_vendor.id,
"product_id": test_product.id,
"location": test_inventory.location,
"quantity": -100,
}
response = client.post(
"/api/v1/admin/inventory/adjust",
headers=admin_headers,
json=adjust_data,
)
# Service wraps InsufficientInventoryException in ValidationException
assert response.status_code == 422
data = response.json()
# Error is wrapped - check message contains relevant info
assert "error_code" in data
assert "insufficient" in data.get("message", "").lower() or data["error_code"] in [
"INSUFFICIENT_INVENTORY",
"VALIDATION_ERROR",
]
def test_update_inventory_admin(
self, client, admin_headers, test_inventory
):
"""Test admin updating inventory entry."""
update_data = {
"quantity": 200,
}
response = client.put(
f"/api/v1/admin/inventory/{test_inventory.id}",
headers=admin_headers,
json=update_data,
)
assert response.status_code == 200
data = response.json()
assert data["quantity"] == 200
def test_update_inventory_not_found(self, client, admin_headers):
"""Test admin updating non-existent inventory."""
update_data = {"quantity": 100}
response = client.put(
"/api/v1/admin/inventory/99999",
headers=admin_headers,
json=update_data,
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "INVENTORY_NOT_FOUND"
def test_delete_inventory_admin(
self, client, admin_headers, test_product, test_vendor, db
):
"""Test admin deleting inventory entry."""
# Create a new inventory entry to delete
from models.database.inventory import Inventory
new_inventory = Inventory(
product_id=test_product.id,
vendor_id=test_vendor.id,
location="TO_DELETE_WAREHOUSE",
quantity=50,
)
db.add(new_inventory)
db.commit()
db.refresh(new_inventory)
inventory_id = new_inventory.id
response = client.delete(
f"/api/v1/admin/inventory/{inventory_id}",
headers=admin_headers,
)
assert response.status_code == 200
data = response.json()
assert "message" in data
assert "deleted" in data["message"].lower()
# Verify it's deleted
deleted = db.query(Inventory).filter(Inventory.id == inventory_id).first()
assert deleted is None
def test_delete_inventory_not_found(self, client, admin_headers):
"""Test admin deleting non-existent inventory."""
response = client.delete(
"/api/v1/admin/inventory/99999",
headers=admin_headers,
)
assert response.status_code == 404
data = response.json()
assert data["error_code"] == "INVENTORY_NOT_FOUND"
def test_delete_inventory_non_admin(
self, client, auth_headers, test_inventory
):
"""Test non-admin trying to delete inventory."""
response = client.delete(
f"/api/v1/admin/inventory/{test_inventory.id}",
headers=auth_headers,
)
assert response.status_code == 403