diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py
index 4a38b7bd..ad010e1d 100644
--- a/app/api/v1/admin/__init__.py
+++ b/app/api/v1/admin/__init__.py
@@ -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
diff --git a/app/api/v1/admin/inventory.py b/app/api/v1/admin/inventory.py
new file mode 100644
index 00000000..d5a2cd30
--- /dev/null
+++ b/app/api/v1/admin/inventory.py
@@ -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")
diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py
index e6bba109..0c08fe1d 100644
--- a/app/routes/admin_pages.py
+++ b/app/routes/admin_pages.py
@@ -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
# ============================================================================
diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py
index fce5fb29..f34cfbd9 100644
--- a/app/services/inventory_service.py
+++ b/app/services/inventory_service.py
@@ -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:
diff --git a/app/templates/admin/inventory.html b/app/templates/admin/inventory.html
new file mode 100644
index 00000000..d9a88553
--- /dev/null
+++ b/app/templates/admin/inventory.html
@@ -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') }}
+
+
+
+
+
+
+
+
+
+
+ Total Entries
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+ Total Stock
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+ Available
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+ Low Stock
+
+
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ vendor_selector(
+ ref_name='vendorSelect',
+ id='inventory-vendor-select',
+ placeholder='Filter by vendor...',
+ width='w-64'
+ ) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% call table_wrapper() %}
+
+
+ | Product |
+ Vendor |
+ Location |
+ Quantity |
+ Reserved |
+ Available |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+
+ No inventory entries found
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SKU:
+
+
+ GTIN:
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+ Out of Stock
+
+
+
+
+ Low Stock
+
+
+
+
+ In Stock
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+ {% endcall %}
+
+ {{ pagination(show_condition="!loading && pagination.total > 0") }}
+
+
+
+{% call modal_simple('adjustStockModal', 'Adjust Stock', show_var='showAdjustModal', size='sm') %}
+
+
+
+
Location:
+
Current Stock:
+
+
+
+
+
+
+ {# noqa: FE-008 - Custom stepper with negative values for stock adjustments #}
+
+
+
+
+ Positive = add stock, Negative = remove stock
+
+
+
+
+
+
+
+
+
+
+ New quantity will be:
+
+
+
+
+
+
+
+
+
+{% endcall %}
+
+
+{% call modal_simple('setQuantityModal', 'Set Quantity', show_var='showSetModal', size='sm') %}
+
+
+
+
Location:
+
Current Stock:
+
+
+
+
+
+
+
+
+
+
+
+
+{% endcall %}
+
+
+{% call modal_simple('deleteModal', 'Delete Inventory Entry', show_var='showDeleteModal', size='sm') %}
+
+
+ Are you sure you want to delete this inventory entry?
+
+
+
+
Location:
+
Current Stock:
+
+
+ This action cannot be undone.
+
+
+
+
+
+
+
+{% endcall %}
+{% endblock %}
+
+{% block extra_scripts %}
+
+{% endblock %}
diff --git a/app/templates/admin/partials/sidebar.html b/app/templates/admin/partials/sidebar.html
index 50125d4e..5140a084 100644
--- a/app/templates/admin/partials/sidebar.html
+++ b/app/templates/admin/partials/sidebar.html
@@ -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 %}
diff --git a/docs/guides/inventory-management.md b/docs/guides/inventory-management.md
new file mode 100644
index 00000000..78bda1ad
--- /dev/null
+++ b/docs/guides/inventory-management.md
@@ -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)
diff --git a/docs/implementation/inventory-admin-migration.md b/docs/implementation/inventory-admin-migration.md
new file mode 100644
index 00000000..8904e98d
--- /dev/null
+++ b/docs/implementation/inventory-admin-migration.md
@@ -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
+
+
+
+
+
+ Inventory
+
+
+
+```
+
+### 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)
diff --git a/models/schema/inventory.py b/models/schema/inventory.py
index 1e33efad..9a673c05 100644
--- a/models/schema/inventory.py
+++ b/models/schema/inventory.py
@@ -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]
diff --git a/static/admin/js/init-alpine.js b/static/admin/js/init-alpine.js
index a612772e..b8a094e1 100644
--- a/static/admin/js/init-alpine.js
+++ b/static/admin/js/init-alpine.js
@@ -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
diff --git a/static/admin/js/inventory.js b/static/admin/js/inventory.js
new file mode 100644
index 00000000..6d0fdd63
--- /dev/null
+++ b/static/admin/js/inventory.js
@@ -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();
+ }
+ }
+ };
+}
diff --git a/tests/integration/api/v1/admin/test_inventory.py b/tests/integration/api/v1/admin/test_inventory.py
new file mode 100644
index 00000000..4a43a266
--- /dev/null
+++ b/tests/integration/api/v1/admin/test_inventory.py
@@ -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