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:
@@ -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
|
||||
|
||||
286
app/api/v1/admin/inventory.py
Normal file
286
app/api/v1/admin/inventory.py
Normal 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")
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
@@ -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:
|
||||
|
||||
440
app/templates/admin/inventory.html
Normal file
440
app/templates/admin/inventory.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
366
docs/guides/inventory-management.md
Normal file
366
docs/guides/inventory-management.md
Normal 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)
|
||||
370
docs/implementation/inventory-admin-migration.md
Normal file
370
docs/implementation/inventory-admin-migration.md
Normal 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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
426
static/admin/js/inventory.js
Normal file
426
static/admin/js/inventory.js
Normal 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
497
tests/integration/api/v1/admin/test_inventory.py
Normal file
497
tests/integration/api/v1/admin/test_inventory.py
Normal 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
|
||||
Reference in New Issue
Block a user