diff --git a/app/api/v1/admin/__init__.py b/app/api/v1/admin/__init__.py index 4a38b7bd..ad010e1d 100644 --- a/app/api/v1/admin/__init__.py +++ b/app/api/v1/admin/__init__.py @@ -32,11 +32,13 @@ from . import ( companies, content_pages, dashboard, + inventory, letzshop, logs, marketplace, monitoring, notifications, + orders, products, settings, tests, @@ -98,7 +100,7 @@ router.include_router(dashboard.router, tags=["admin-dashboard"]) # ============================================================================ -# Product Catalog +# Vendor Operations (Product Catalog, Inventory & Orders) # ============================================================================ # Include marketplace product catalog management endpoints @@ -107,6 +109,12 @@ router.include_router(products.router, tags=["admin-marketplace-products"]) # Include vendor product catalog management endpoints router.include_router(vendor_products.router, tags=["admin-vendor-products"]) +# Include inventory management endpoints +router.include_router(inventory.router, tags=["admin-inventory"]) + +# Include order management endpoints +router.include_router(orders.router, tags=["admin-orders"]) + # ============================================================================ # Marketplace & Imports diff --git a/app/api/v1/admin/inventory.py b/app/api/v1/admin/inventory.py new file mode 100644 index 00000000..d5a2cd30 --- /dev/null +++ b/app/api/v1/admin/inventory.py @@ -0,0 +1,286 @@ +# app/api/v1/admin/inventory.py +""" +Admin inventory management endpoints. + +Provides inventory management capabilities for administrators: +- View inventory across all vendors +- View vendor-specific inventory +- Set/adjust inventory on behalf of vendors +- Low stock alerts and reporting + +Admin Context: Uses admin JWT authentication. +Vendor selection is passed as a request parameter. +""" + +import logging + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.deps import get_current_admin_api +from app.core.database import get_db +from app.services.inventory_service import inventory_service +from models.database.user import User +from models.schema.inventory import ( + AdminInventoryAdjust, + AdminInventoryCreate, + AdminInventoryListResponse, + AdminInventoryLocationsResponse, + AdminInventoryStats, + AdminLowStockItem, + AdminVendorsWithInventoryResponse, + InventoryAdjust, + InventoryCreate, + InventoryMessageResponse, + InventoryResponse, + InventoryUpdate, + ProductInventorySummary, +) + +router = APIRouter(prefix="/inventory") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# List & Statistics Endpoints +# ============================================================================ + + +@router.get("", response_model=AdminInventoryListResponse) +def get_all_inventory( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=500), + vendor_id: int | None = Query(None, description="Filter by vendor"), + location: str | None = Query(None, description="Filter by location"), + low_stock: int | None = Query(None, ge=0, description="Filter items below threshold"), + search: str | None = Query(None, description="Search by product title or SKU"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Get inventory across all vendors with filtering. + + Allows admins to view and filter inventory across the platform. + """ + return inventory_service.get_all_inventory_admin( + db=db, + skip=skip, + limit=limit, + vendor_id=vendor_id, + location=location, + low_stock=low_stock, + search=search, + ) + + +@router.get("/stats", response_model=AdminInventoryStats) +def get_inventory_stats( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get platform-wide inventory statistics.""" + return inventory_service.get_inventory_stats_admin(db) + + +@router.get("/low-stock", response_model=list[AdminLowStockItem]) +def get_low_stock_items( + threshold: int = Query(10, ge=0, description="Stock threshold"), + vendor_id: int | None = Query(None, description="Filter by vendor"), + limit: int = Query(50, ge=1, le=200), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get items with low stock levels.""" + return inventory_service.get_low_stock_items_admin( + db=db, + threshold=threshold, + vendor_id=vendor_id, + limit=limit, + ) + + +@router.get("/vendors", response_model=AdminVendorsWithInventoryResponse) +def get_vendors_with_inventory( + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get list of vendors that have inventory entries.""" + return inventory_service.get_vendors_with_inventory_admin(db) + + +@router.get("/locations", response_model=AdminInventoryLocationsResponse) +def get_inventory_locations( + vendor_id: int | None = Query(None, description="Filter by vendor"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get list of unique inventory locations.""" + return inventory_service.get_inventory_locations_admin(db, vendor_id) + + +# ============================================================================ +# Vendor-Specific Endpoints +# ============================================================================ + + +@router.get("/vendors/{vendor_id}", response_model=AdminInventoryListResponse) +def get_vendor_inventory( + vendor_id: int, + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=500), + location: str | None = Query(None, description="Filter by location"), + low_stock: int | None = Query(None, ge=0, description="Filter items below threshold"), + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get inventory for a specific vendor.""" + return inventory_service.get_vendor_inventory_admin( + db=db, + vendor_id=vendor_id, + skip=skip, + limit=limit, + location=location, + low_stock=low_stock, + ) + + +@router.get("/products/{product_id}", response_model=ProductInventorySummary) +def get_product_inventory( + product_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Get inventory summary for a specific product across all locations.""" + return inventory_service.get_product_inventory_admin(db, product_id) + + +# ============================================================================ +# Inventory Modification Endpoints +# ============================================================================ + + +@router.post("/set", response_model=InventoryResponse) +def set_inventory( + inventory_data: AdminInventoryCreate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Set exact inventory quantity for a product at a location. + + Admin version - requires explicit vendor_id in request body. + """ + # Verify vendor exists + inventory_service.verify_vendor_exists(db, inventory_data.vendor_id) + + # Convert to standard schema for service + service_data = InventoryCreate( + product_id=inventory_data.product_id, + location=inventory_data.location, + quantity=inventory_data.quantity, + ) + + result = inventory_service.set_inventory( + db=db, + vendor_id=inventory_data.vendor_id, + inventory_data=service_data, + ) + + logger.info( + f"Admin {current_admin.email} set inventory for product {inventory_data.product_id} " + f"at {inventory_data.location}: {inventory_data.quantity} units" + ) + + db.commit() + return result + + +@router.post("/adjust", response_model=InventoryResponse) +def adjust_inventory( + adjustment: AdminInventoryAdjust, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """ + Adjust inventory by adding or removing quantity. + + Positive quantity = add stock, negative = remove stock. + Admin version - requires explicit vendor_id in request body. + """ + # Verify vendor exists + inventory_service.verify_vendor_exists(db, adjustment.vendor_id) + + # Convert to standard schema for service + service_data = InventoryAdjust( + product_id=adjustment.product_id, + location=adjustment.location, + quantity=adjustment.quantity, + ) + + result = inventory_service.adjust_inventory( + db=db, + vendor_id=adjustment.vendor_id, + inventory_data=service_data, + ) + + sign = "+" if adjustment.quantity >= 0 else "" + logger.info( + f"Admin {current_admin.email} adjusted inventory for product {adjustment.product_id} " + f"at {adjustment.location}: {sign}{adjustment.quantity} units" + f"{f' (reason: {adjustment.reason})' if adjustment.reason else ''}" + ) + + db.commit() + return result + + +@router.put("/{inventory_id}", response_model=InventoryResponse) +def update_inventory( + inventory_id: int, + inventory_update: InventoryUpdate, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Update inventory entry fields.""" + # Get inventory to find vendor_id + inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id) + + result = inventory_service.update_inventory( + db=db, + vendor_id=inventory.vendor_id, + inventory_id=inventory_id, + inventory_update=inventory_update, + ) + + logger.info(f"Admin {current_admin.email} updated inventory {inventory_id}") + + db.commit() + return result + + +@router.delete("/{inventory_id}", response_model=InventoryMessageResponse) +def delete_inventory( + inventory_id: int, + db: Session = Depends(get_db), + current_admin: User = Depends(get_current_admin_api), +): + """Delete inventory entry.""" + # Get inventory to find vendor_id and log details + inventory = inventory_service.get_inventory_by_id_admin(db, inventory_id) + vendor_id = inventory.vendor_id + product_id = inventory.product_id + location = inventory.location + + inventory_service.delete_inventory( + db=db, + vendor_id=vendor_id, + inventory_id=inventory_id, + ) + + logger.info( + f"Admin {current_admin.email} deleted inventory {inventory_id} " + f"(product {product_id} at {location})" + ) + + db.commit() + return InventoryMessageResponse(message="Inventory deleted successfully") diff --git a/app/routes/admin_pages.py b/app/routes/admin_pages.py index e6bba109..0c08fe1d 100644 --- a/app/routes/admin_pages.py +++ b/app/routes/admin_pages.py @@ -24,6 +24,8 @@ Routes: - GET /vendors/{vendor_code}/theme → Vendor theme editor (auth required) - GET /users → User management page (auth required) - GET /customers → Customer management page (auth required) +- GET /inventory → Inventory management page (auth required) +- GET /orders → Orders management page (auth required) - GET /imports → Import history page (auth required) - GET /marketplace-products → Marketplace products catalog (auth required) - GET /vendor-products → Vendor products catalog (auth required) @@ -474,6 +476,54 @@ async def admin_customers_page( ) +# ============================================================================ +# INVENTORY MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/inventory", response_class=HTMLResponse, include_in_schema=False) +async def admin_inventory_page( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render inventory management page. + Shows stock levels across all vendors with filtering and adjustment capabilities. + """ + return templates.TemplateResponse( + "admin/inventory.html", + { + "request": request, + "user": current_user, + }, + ) + + +# ============================================================================ +# ORDER MANAGEMENT ROUTES +# ============================================================================ + + +@router.get("/orders", response_class=HTMLResponse, include_in_schema=False) +async def admin_orders_page( + request: Request, + current_user: User = Depends(get_current_admin_from_cookie_or_header), + db: Session = Depends(get_db), +): + """ + Render orders management page. + Shows orders across all vendors with filtering and status management. + """ + return templates.TemplateResponse( + "admin/orders.html", + { + "request": request, + "user": current_user, + }, + ) + + # ============================================================================ # IMPORT MANAGEMENT ROUTES # ============================================================================ diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py index fce5fb29..f34cfbd9 100644 --- a/app/services/inventory_service.py +++ b/app/services/inventory_service.py @@ -2,6 +2,7 @@ import logging from datetime import UTC, datetime +from sqlalchemy import func from sqlalchemy.orm import Session from app.exceptions import ( @@ -11,10 +12,19 @@ from app.exceptions import ( InventoryValidationException, ProductNotFoundException, ValidationException, + VendorNotFoundException, ) from models.database.inventory import Inventory from models.database.product import Product +from models.database.vendor import Vendor from models.schema.inventory import ( + AdminInventoryItem, + AdminInventoryListResponse, + AdminInventoryLocationsResponse, + AdminInventoryStats, + AdminLowStockItem, + AdminVendorsWithInventoryResponse, + AdminVendorWithInventory, InventoryAdjust, InventoryCreate, InventoryLocationResponse, @@ -547,7 +557,325 @@ class InventoryService: logger.error(f"Error deleting inventory: {str(e)}") raise ValidationException("Failed to delete inventory") + # ========================================================================= + # Admin Methods (cross-vendor operations) + # ========================================================================= + + def get_all_inventory_admin( + self, + db: Session, + skip: int = 0, + limit: int = 50, + vendor_id: int | None = None, + location: str | None = None, + low_stock: int | None = None, + search: str | None = None, + ) -> AdminInventoryListResponse: + """ + Get inventory across all vendors with filtering (admin only). + + Args: + db: Database session + skip: Pagination offset + limit: Pagination limit + vendor_id: Filter by vendor + location: Filter by location + low_stock: Filter items below threshold + search: Search by product title or SKU + + Returns: + AdminInventoryListResponse + """ + query = db.query(Inventory).join(Product).join(Vendor) + + # Apply filters + if vendor_id is not None: + query = query.filter(Inventory.vendor_id == vendor_id) + + if location: + query = query.filter(Inventory.location.ilike(f"%{location}%")) + + if low_stock is not None: + query = query.filter(Inventory.quantity <= low_stock) + + if search: + from models.database.marketplace_product import MarketplaceProduct + from models.database.marketplace_product_translation import ( + MarketplaceProductTranslation, + ) + + query = ( + query.join(MarketplaceProduct) + .outerjoin(MarketplaceProductTranslation) + .filter( + (MarketplaceProductTranslation.title.ilike(f"%{search}%")) + | (Product.vendor_sku.ilike(f"%{search}%")) + ) + ) + + # Get total count before pagination + total = query.count() + + # Apply pagination + inventories = query.offset(skip).limit(limit).all() + + # Build response with vendor/product info + items = [] + for inv in inventories: + product = inv.product + vendor = inv.vendor + title = None + if product and product.marketplace_product: + title = product.marketplace_product.get_title() + + items.append( + AdminInventoryItem( + id=inv.id, + product_id=inv.product_id, + vendor_id=inv.vendor_id, + vendor_name=vendor.name if vendor else None, + vendor_code=vendor.vendor_code if vendor else None, + product_title=title, + product_sku=product.vendor_sku if product else None, + location=inv.location, + quantity=inv.quantity, + reserved_quantity=inv.reserved_quantity, + available_quantity=inv.available_quantity, + gtin=inv.gtin, + created_at=inv.created_at, + updated_at=inv.updated_at, + ) + ) + + return AdminInventoryListResponse( + inventories=items, + total=total, + skip=skip, + limit=limit, + vendor_filter=vendor_id, + location_filter=location, + ) + + def get_inventory_stats_admin(self, db: Session) -> AdminInventoryStats: + """Get platform-wide inventory statistics (admin only).""" + # Total entries + total_entries = db.query(func.count(Inventory.id)).scalar() or 0 + + # Aggregate quantities + totals = db.query( + func.sum(Inventory.quantity).label("total_qty"), + func.sum(Inventory.reserved_quantity).label("total_reserved"), + ).first() + + total_quantity = totals.total_qty or 0 + total_reserved = totals.total_reserved or 0 + total_available = total_quantity - total_reserved + + # Low stock count (default threshold: 10) + low_stock_count = ( + db.query(func.count(Inventory.id)) + .filter(Inventory.quantity <= 10) + .scalar() + or 0 + ) + + # Vendors with inventory + vendors_with_inventory = ( + db.query(func.count(func.distinct(Inventory.vendor_id))).scalar() or 0 + ) + + # Unique locations + unique_locations = ( + db.query(func.count(func.distinct(Inventory.location))).scalar() or 0 + ) + + return AdminInventoryStats( + total_entries=total_entries, + total_quantity=total_quantity, + total_reserved=total_reserved, + total_available=total_available, + low_stock_count=low_stock_count, + vendors_with_inventory=vendors_with_inventory, + unique_locations=unique_locations, + ) + + def get_low_stock_items_admin( + self, + db: Session, + threshold: int = 10, + vendor_id: int | None = None, + limit: int = 50, + ) -> list[AdminLowStockItem]: + """Get items with low stock levels (admin only).""" + query = ( + db.query(Inventory) + .join(Product) + .join(Vendor) + .filter(Inventory.quantity <= threshold) + ) + + if vendor_id is not None: + query = query.filter(Inventory.vendor_id == vendor_id) + + # Order by quantity ascending (most critical first) + query = query.order_by(Inventory.quantity.asc()) + + inventories = query.limit(limit).all() + + items = [] + for inv in inventories: + product = inv.product + vendor = inv.vendor + title = None + if product and product.marketplace_product: + title = product.marketplace_product.get_title() + + items.append( + AdminLowStockItem( + id=inv.id, + product_id=inv.product_id, + vendor_id=inv.vendor_id, + vendor_name=vendor.name if vendor else None, + product_title=title, + location=inv.location, + quantity=inv.quantity, + reserved_quantity=inv.reserved_quantity, + available_quantity=inv.available_quantity, + ) + ) + + return items + + def get_vendors_with_inventory_admin( + self, db: Session + ) -> AdminVendorsWithInventoryResponse: + """Get list of vendors that have inventory entries (admin only).""" + vendors = ( + db.query(Vendor).join(Inventory).distinct().order_by(Vendor.name).all() + ) + + return AdminVendorsWithInventoryResponse( + vendors=[ + AdminVendorWithInventory( + id=v.id, name=v.name, vendor_code=v.vendor_code + ) + for v in vendors + ] + ) + + def get_inventory_locations_admin( + self, db: Session, vendor_id: int | None = None + ) -> AdminInventoryLocationsResponse: + """Get list of unique inventory locations (admin only).""" + query = db.query(func.distinct(Inventory.location)) + + if vendor_id is not None: + query = query.filter(Inventory.vendor_id == vendor_id) + + locations = [loc[0] for loc in query.all()] + + return AdminInventoryLocationsResponse(locations=sorted(locations)) + + def get_vendor_inventory_admin( + self, + db: Session, + vendor_id: int, + skip: int = 0, + limit: int = 50, + location: str | None = None, + low_stock: int | None = None, + ) -> AdminInventoryListResponse: + """Get inventory for a specific vendor (admin only).""" + # Verify vendor exists + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise VendorNotFoundException(f"Vendor {vendor_id} not found") + + # Use the existing method + inventories = self.get_vendor_inventory( + db=db, + vendor_id=vendor_id, + skip=skip, + limit=limit, + location=location, + low_stock_threshold=low_stock, + ) + + # Build response with product info + items = [] + for inv in inventories: + product = inv.product + title = None + if product and product.marketplace_product: + title = product.marketplace_product.get_title() + + items.append( + AdminInventoryItem( + id=inv.id, + product_id=inv.product_id, + vendor_id=inv.vendor_id, + vendor_name=vendor.name, + vendor_code=vendor.vendor_code, + product_title=title, + product_sku=product.vendor_sku if product else None, + location=inv.location, + quantity=inv.quantity, + reserved_quantity=inv.reserved_quantity, + available_quantity=inv.available_quantity, + gtin=inv.gtin, + created_at=inv.created_at, + updated_at=inv.updated_at, + ) + ) + + # Get total count for pagination + total_query = db.query(func.count(Inventory.id)).filter( + Inventory.vendor_id == vendor_id + ) + if location: + total_query = total_query.filter(Inventory.location.ilike(f"%{location}%")) + if low_stock is not None: + total_query = total_query.filter(Inventory.quantity <= low_stock) + total = total_query.scalar() or 0 + + return AdminInventoryListResponse( + inventories=items, + total=total, + skip=skip, + limit=limit, + vendor_filter=vendor_id, + location_filter=location, + ) + + def get_product_inventory_admin( + self, db: Session, product_id: int + ) -> ProductInventorySummary: + """Get inventory summary for a product (admin only - no vendor check).""" + product = db.query(Product).filter(Product.id == product_id).first() + if not product: + raise ProductNotFoundException(f"Product {product_id} not found") + + # Use existing method with the product's vendor_id + return self.get_product_inventory(db, product.vendor_id, product_id) + + def verify_vendor_exists(self, db: Session, vendor_id: int) -> Vendor: + """Verify vendor exists and return it.""" + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + if not vendor: + raise VendorNotFoundException(f"Vendor {vendor_id} not found") + return vendor + + def get_inventory_by_id_admin(self, db: Session, inventory_id: int) -> Inventory: + """Get inventory by ID (admin only - returns inventory with vendor_id).""" + inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first() + if not inventory: + raise InventoryNotFoundException(f"Inventory {inventory_id} not found") + return inventory + + # ========================================================================= # Private helper methods + # ========================================================================= + def _get_vendor_product( self, db: Session, vendor_id: int, product_id: int ) -> Product: diff --git a/app/templates/admin/inventory.html b/app/templates/admin/inventory.html new file mode 100644 index 00000000..d9a88553 --- /dev/null +++ b/app/templates/admin/inventory.html @@ -0,0 +1,440 @@ +{# app/templates/admin/inventory.html #} +{% extends "admin/base.html" %} +{% from 'shared/macros/pagination.html' import pagination %} +{% from 'shared/macros/headers.html' import page_header %} +{% from 'shared/macros/alerts.html' import loading_state, error_state %} +{% from 'shared/macros/tables.html' import table_wrapper %} +{% from 'shared/macros/modals.html' import modal_simple %} +{% from 'shared/macros/inputs.html' import vendor_selector %} + +{% block title %}Inventory{% endblock %} + +{% block alpine_data %}adminInventory(){% endblock %} + +{% block content %} +{{ page_header('Inventory', subtitle='Manage stock levels across all vendors') }} + +{{ loading_state('Loading inventory...') }} + +{{ error_state('Error loading inventory') }} + + +
+ +
+
+ +
+
+

+ Total Entries +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Total Stock +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Available +

+

+ 0 +

+
+
+ + +
+
+ +
+
+

+ Low Stock +

+

+ 0 +

+
+
+
+ + +
+
+ +
+
+ + + + +
+
+ + +
+ + {{ vendor_selector( + ref_name='vendorSelect', + id='inventory-vendor-select', + placeholder='Filter by vendor...', + width='w-64' + ) }} + + + + + + + + + +
+
+
+ + +
+ {% call table_wrapper() %} + + + Product + Vendor + Location + Quantity + Reserved + Available + Status + Actions + + + + + + + + + + {% endcall %} + + {{ pagination(show_condition="!loading && pagination.total > 0") }} +
+ + +{% call modal_simple('adjustStockModal', 'Adjust Stock', show_var='showAdjustModal', size='sm') %} +
+
+

+

Location:

+

Current Stock:

+
+ +
+ +
+ + {# noqa: FE-008 - Custom stepper with negative values for stock adjustments #} + + +
+

+ Positive = add stock, Negative = remove stock +

+
+ +
+ + +
+ +
+

+ New quantity will be: + +

+
+ +
+ + +
+
+{% endcall %} + + +{% call modal_simple('setQuantityModal', 'Set Quantity', show_var='showSetModal', size='sm') %} +
+
+

+

Location:

+

Current Stock:

+
+ +
+ + +
+ +
+ + +
+
+{% endcall %} + + +{% call modal_simple('deleteModal', 'Delete Inventory Entry', show_var='showDeleteModal', size='sm') %} +
+

+ Are you sure you want to delete this inventory entry? +

+
+

+

Location:

+

Current Stock:

+
+

+ This action cannot be undone. +

+ +
+ + +
+
+{% endcall %} +{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/app/templates/admin/partials/sidebar.html b/app/templates/admin/partials/sidebar.html index 50125d4e..5140a084 100644 --- a/app/templates/admin/partials/sidebar.html +++ b/app/templates/admin/partials/sidebar.html @@ -80,9 +80,9 @@ {{ menu_item('marketplace-products', '/admin/marketplace-products', 'database', 'Marketplace Products') }} {{ menu_item('vendor-products', '/admin/vendor-products', 'cube', 'Vendor Products') }} {{ menu_item('customers', '/admin/customers', 'user-group', 'Customers') }} - {# Future items - uncomment when implemented: {{ menu_item('inventory', '/admin/inventory', 'archive', 'Inventory') }} {{ menu_item('orders', '/admin/orders', 'clipboard-list', 'Orders') }} + {# Future items - uncomment when implemented: {{ menu_item('shipping', '/admin/shipping', 'truck', 'Shipping') }} #} {% endcall %} diff --git a/docs/guides/inventory-management.md b/docs/guides/inventory-management.md new file mode 100644 index 00000000..78bda1ad --- /dev/null +++ b/docs/guides/inventory-management.md @@ -0,0 +1,366 @@ +# Inventory Management + +## Overview + +The Wizamart platform provides comprehensive inventory management with support for: + +- **Multi-location tracking** - Track stock across warehouses, stores, and storage bins +- **Reservation system** - Reserve items for pending orders +- **Digital products** - Automatic unlimited inventory for digital goods +- **Admin operations** - Manage inventory on behalf of vendors + +--- + +## Key Concepts + +### Storage Locations + +Inventory is tracked at the **storage location level**. Each product can have stock in multiple locations: + +``` +Product: "Wireless Headphones" +├── WAREHOUSE_MAIN: 100 units (10 reserved) +├── WAREHOUSE_WEST: 50 units (0 reserved) +└── STORE_FRONT: 25 units (5 reserved) + +Total: 175 units | Reserved: 15 | Available: 160 +``` + +**Location naming:** Locations are text strings, normalized to UPPERCASE (e.g., `WAREHOUSE_A`, `STORE_01`). + +### Inventory States + +| Field | Description | +|-------|-------------| +| `quantity` | Total physical stock at location | +| `reserved_quantity` | Items reserved for pending orders | +| `available_quantity` | `quantity - reserved_quantity` (can be sold) | + +### Product Types & Inventory + +| Product Type | Inventory Behavior | +|--------------|-------------------| +| **Physical** | Requires inventory tracking, orders check available stock | +| **Digital** | **Unlimited inventory** - no stock constraints | +| **Service** | Treated as digital (unlimited) | +| **Subscription** | Treated as digital (unlimited) | + +--- + +## Digital Products + +Digital products have **unlimited inventory** by default. This means: + +- Orders for digital products never fail due to "insufficient inventory" +- No need to create inventory entries for digital products +- The `available_inventory` property returns `999999` (effectively unlimited) + +### How It Works + +```python +# In Product model +@property +def has_unlimited_inventory(self) -> bool: + """Digital products have unlimited inventory.""" + return self.is_digital + +@property +def available_inventory(self) -> int: + """Calculate available inventory.""" + if self.has_unlimited_inventory: + return 999999 # Unlimited + return sum(inv.available_quantity for inv in self.inventory_entries) +``` + +### Setting a Product as Digital + +Digital products are identified by the `is_digital` flag on the `MarketplaceProduct`: + +```python +marketplace_product.is_digital = True +marketplace_product.product_type_enum = "digital" +marketplace_product.digital_delivery_method = "license_key" # or "download", "email" +``` + +--- + +## Inventory Operations + +### Set Inventory + +Replace the exact quantity at a location: + +```http +POST /api/v1/vendor/inventory/set +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 100 +} +``` + +### Adjust Inventory + +Add or remove stock (positive = add, negative = remove): + +```http +POST /api/v1/vendor/inventory/adjust +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": -10 // Remove 10 units +} +``` + +### Reserve Inventory + +Mark items as reserved for an order: + +```http +POST /api/v1/vendor/inventory/reserve +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 5 +} +``` + +### Release Reservation + +Cancel a reservation (order cancelled): + +```http +POST /api/v1/vendor/inventory/release +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 5 +} +``` + +### Fulfill Reservation + +Complete an order (items shipped): + +```http +POST /api/v1/vendor/inventory/fulfill +{ + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 5 +} +``` + +This decreases both `quantity` and `reserved_quantity`. + +--- + +## Reservation Workflow + +``` +┌─────────────────┐ +│ Order Created │ +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Reserve Items │ reserved_quantity += order_qty +└────────┬────────┘ + │ + ┌────┴────┐ + │ │ + ▼ ▼ +┌───────┐ ┌──────────┐ +│Cancel │ │ Ship │ +└───┬───┘ └────┬─────┘ + │ │ + ▼ ▼ +┌─────────┐ ┌──────────────┐ +│ Release │ │ Fulfill │ +│reserved │ │ quantity -= │ +│ -= qty │ │ reserved -= │ +└─────────┘ └──────────────┘ +``` + +--- + +## Admin Inventory Management + +Administrators can manage inventory on behalf of any vendor through the admin UI at `/admin/inventory` or via the API. + +### Admin UI Features + +The admin inventory page provides: + +- **Overview Statistics** - Total entries, stock quantities, reserved items, and low stock alerts +- **Filtering** - Filter by vendor, location, and low stock threshold +- **Search** - Search by product title or SKU +- **Stock Adjustment** - Add or remove stock with optional reason tracking +- **Set Quantity** - Set exact stock quantity at any location +- **Delete Entries** - Remove inventory entries + +### Admin API Endpoints + +### List All Inventory + +```http +GET /api/v1/admin/inventory +GET /api/v1/admin/inventory?vendor_id=1 +GET /api/v1/admin/inventory?low_stock=10 +``` + +### Get Inventory Statistics + +```http +GET /api/v1/admin/inventory/stats + +Response: +{ + "total_entries": 150, + "total_quantity": 5000, + "total_reserved": 200, + "total_available": 4800, + "low_stock_count": 12, + "vendors_with_inventory": 5, + "unique_locations": 8 +} +``` + +### Low Stock Alerts + +```http +GET /api/v1/admin/inventory/low-stock?threshold=10 + +Response: +[ + { + "product_id": 123, + "vendor_name": "TechStore", + "product_title": "USB Cable", + "location": "WAREHOUSE_A", + "quantity": 3, + "available_quantity": 2 + } +] +``` + +### Set Inventory (Admin) + +```http +POST /api/v1/admin/inventory/set +{ + "vendor_id": 1, + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 100 +} +``` + +### Adjust Inventory (Admin) + +```http +POST /api/v1/admin/inventory/adjust +{ + "vendor_id": 1, + "product_id": 123, + "location": "WAREHOUSE_A", + "quantity": 25, + "reason": "Restocking from supplier" +} +``` + +--- + +## Database Schema + +### Inventory Table + +```sql +CREATE TABLE inventory ( + id SERIAL PRIMARY KEY, + product_id INTEGER NOT NULL REFERENCES products(id), + vendor_id INTEGER NOT NULL REFERENCES vendors(id), + location VARCHAR NOT NULL, + quantity INTEGER NOT NULL DEFAULT 0, + reserved_quantity INTEGER DEFAULT 0, + gtin VARCHAR, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + UNIQUE(product_id, location) +); + +CREATE INDEX idx_inventory_vendor_product ON inventory(vendor_id, product_id); +CREATE INDEX idx_inventory_product_location ON inventory(product_id, location); +``` + +### Constraints + +- **Unique constraint:** `(product_id, location)` - One entry per product/location +- **Foreign keys:** References `products` and `vendors` tables +- **Non-negative:** `quantity` and `reserved_quantity` must be >= 0 + +--- + +## Best Practices + +### Physical Products + +1. **Create inventory entries** before accepting orders +2. **Use meaningful location names** (e.g., `WAREHOUSE_MAIN`, `STORE_NYC`) +3. **Monitor low stock** using the admin dashboard or API +4. **Reserve on order creation** to prevent overselling + +### Digital Products + +1. **No inventory setup needed** - unlimited by default +2. **Optional:** Create entries for license key tracking +3. **Focus on fulfillment** - digital delivery mechanism + +### Multi-Location + +1. **Aggregate queries** use `Product.total_inventory` and `Product.available_inventory` +2. **Location-specific** operations use the Inventory model directly +3. **Transfers** between locations: adjust down at source, adjust up at destination + +--- + +## API Reference + +### Vendor Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/vendor/inventory/set` | Set exact quantity | +| POST | `/api/v1/vendor/inventory/adjust` | Add/remove quantity | +| POST | `/api/v1/vendor/inventory/reserve` | Reserve for order | +| POST | `/api/v1/vendor/inventory/release` | Cancel reservation | +| POST | `/api/v1/vendor/inventory/fulfill` | Complete order | +| GET | `/api/v1/vendor/inventory/product/{id}` | Product summary | +| GET | `/api/v1/vendor/inventory` | List with filters | +| PUT | `/api/v1/vendor/inventory/{id}` | Update entry | +| DELETE | `/api/v1/vendor/inventory/{id}` | Delete entry | + +### Admin Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/admin/inventory` | List all (cross-vendor) | +| GET | `/api/v1/admin/inventory/stats` | Platform statistics | +| GET | `/api/v1/admin/inventory/low-stock` | Low stock alerts | +| GET | `/api/v1/admin/inventory/vendors` | Vendors with inventory | +| GET | `/api/v1/admin/inventory/locations` | Unique locations | +| GET | `/api/v1/admin/inventory/vendors/{id}` | Vendor inventory | +| GET | `/api/v1/admin/inventory/products/{id}` | Product summary | +| POST | `/api/v1/admin/inventory/set` | Set (requires vendor_id) | +| POST | `/api/v1/admin/inventory/adjust` | Adjust (requires vendor_id) | +| PUT | `/api/v1/admin/inventory/{id}` | Update entry | +| DELETE | `/api/v1/admin/inventory/{id}` | Delete entry | + +--- + +## Related Documentation + +- [Product Management](product-management.md) +- [Admin Inventory Migration Plan](../implementation/inventory-admin-migration.md) +- [Vendor Operations Expansion](../development/migration/vendor-operations-expansion.md) diff --git a/docs/implementation/inventory-admin-migration.md b/docs/implementation/inventory-admin-migration.md new file mode 100644 index 00000000..8904e98d --- /dev/null +++ b/docs/implementation/inventory-admin-migration.md @@ -0,0 +1,370 @@ +# Admin Inventory Management Migration Plan + +## Overview + +**Objective:** Add inventory management capabilities to the admin "Vendor Operations" section, allowing administrators to view and manage vendor inventory on their behalf. + +**Status:** Phase 1 Complete + +--- + +## Current State Analysis + +### What Exists + +The inventory system is **fully implemented at the vendor API level** with comprehensive functionality: + +| Component | Status | Location | +|-----------|--------|----------| +| Database Model | ✅ Complete | `models/database/inventory.py` | +| Pydantic Schemas | ✅ Complete | `models/schema/inventory.py` | +| Service Layer | ✅ Complete | `app/services/inventory_service.py` | +| Vendor API | ✅ Complete | `app/api/v1/vendor/inventory.py` | +| Exceptions | ✅ Complete | `app/exceptions/inventory.py` | +| Unit Tests | ✅ Complete | `tests/unit/services/test_inventory_service.py` | +| Integration Tests | ✅ Complete | `tests/integration/api/v1/vendor/test_inventory.py` | +| Vendor UI | 🔲 Placeholder | `app/templates/vendor/inventory.html` | +| Admin API | 🔲 Not Started | - | +| Admin UI | 🔲 Not Started | - | +| Audit Trail | 🔲 Not Started | Logs only, no dedicated table | + +### Storage/Location Architecture + +The inventory system tracks stock at the **storage location level**: + +``` +Product A +├── WAREHOUSE_MAIN: 100 units (10 reserved) +├── WAREHOUSE_WEST: 50 units (0 reserved) +└── STORE_FRONT: 25 units (5 reserved) + +Total: 175 units | Reserved: 15 | Available: 160 +``` + +**Key design decisions:** +- One product can have inventory across multiple locations +- Unique constraint: `(product_id, location)` - one entry per product/location +- Locations are text strings, normalized to UPPERCASE +- Available quantity = `quantity - reserved_quantity` + +### Existing Operations + +| Operation | Description | Service Method | +|-----------|-------------|----------------| +| **Set** | Replace exact quantity at location | `set_inventory()` | +| **Adjust** | Add/remove quantity (positive/negative) | `adjust_inventory()` | +| **Reserve** | Mark items for pending order | `reserve_inventory()` | +| **Release** | Cancel reservation | `release_reservation()` | +| **Fulfill** | Complete order (reduces both qty & reserved) | `fulfill_reservation()` | +| **Get Product** | Summary across all locations | `get_product_inventory()` | +| **Get Vendor** | List with filters | `get_vendor_inventory()` | +| **Update** | Partial field update | `update_inventory()` | +| **Delete** | Remove inventory entry | `delete_inventory()` | + +### Vendor API Endpoints + +All endpoints at `/api/v1/vendor/inventory/*`: + +| Method | Endpoint | Operation | +|--------|----------|-----------| +| POST | `/inventory/set` | Set exact quantity | +| POST | `/inventory/adjust` | Add/remove quantity | +| POST | `/inventory/reserve` | Reserve for order | +| POST | `/inventory/release` | Cancel reservation | +| POST | `/inventory/fulfill` | Complete order | +| GET | `/inventory/product/{id}` | Product summary | +| GET | `/inventory` | List with filters | +| PUT | `/inventory/{id}` | Update entry | +| DELETE | `/inventory/{id}` | Delete entry | + +--- + +## Gap Analysis + +### What's Missing for Admin Vendor Operations + +1. **Admin API Endpoints** - ✅ Implemented in Phase 1 +2. **Admin UI Page** - No inventory management interface in admin panel +3. **Vendor Selector** - Admin needs to select which vendor to manage +4. **Cross-Vendor View** - ✅ Implemented in Phase 1 +5. **Audit Trail** - Only application logs, no queryable audit history +6. **Bulk Operations** - No bulk adjust/import capabilities +7. **Low Stock Alerts** - Basic filter exists, no alert configuration + +### Digital Products - Infinite Inventory ✅ + +**Implementation Complete** + +Digital products now have unlimited inventory by default: + +```python +# In Product model (models/database/product.py) +UNLIMITED_INVENTORY = 999999 + +@property +def has_unlimited_inventory(self) -> bool: + """Digital products have unlimited inventory.""" + return self.is_digital + +@property +def available_inventory(self) -> int: + """Calculate available inventory (total - reserved). + + Digital products return unlimited inventory. + """ + if self.has_unlimited_inventory: + return self.UNLIMITED_INVENTORY + return sum(inv.available_quantity for inv in self.inventory_entries) +``` + +**Behavior:** +- Physical products: Sum of inventory entries (0 if no entries) +- Digital products: Returns `999999` (unlimited) regardless of entries +- Orders for digital products never fail due to "insufficient inventory" + +**Tests:** `tests/unit/models/database/test_product.py::TestProductInventoryProperties` + +**Documentation:** [Inventory Management Guide](../guides/inventory-management.md) + +--- + +## Migration Plan + +### Phase 1: Admin API Endpoints + +**Goal:** Expose inventory management to admin users with vendor selection + +#### 1.1 New File: `app/api/v1/admin/inventory.py` + +Admin endpoints that mirror vendor functionality with vendor_id as parameter: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/admin/inventory` | List all inventory (cross-vendor) | +| GET | `/admin/inventory/vendors/{vendor_id}` | Vendor-specific inventory | +| GET | `/admin/inventory/products/{product_id}` | Product inventory summary | +| POST | `/admin/inventory/set` | Set inventory (requires vendor_id) | +| POST | `/admin/inventory/adjust` | Adjust inventory | +| PUT | `/admin/inventory/{id}` | Update inventory entry | +| DELETE | `/admin/inventory/{id}` | Delete inventory entry | +| GET | `/admin/inventory/low-stock` | Low stock report | + +#### 1.2 Schema Extensions + +Add admin-specific request schemas in `models/schema/inventory.py`: + +```python +class AdminInventoryCreate(InventoryCreate): + """Admin version - requires explicit vendor_id.""" + vendor_id: int = Field(..., description="Target vendor ID") + +class AdminInventoryAdjust(InventoryAdjust): + """Admin version - requires explicit vendor_id.""" + vendor_id: int = Field(..., description="Target vendor ID") + +class AdminInventoryListResponse(BaseModel): + """Cross-vendor inventory list.""" + inventories: list[InventoryResponse] + total: int + skip: int + limit: int + vendor_filter: int | None = None +``` + +#### 1.3 Service Layer Reuse + +The existing `InventoryService` already accepts `vendor_id` as a parameter - **no service changes needed**. Admin endpoints simply pass the vendor_id from the request instead of from the JWT token. + +### Phase 2: Admin UI + +**Goal:** Create admin inventory management page + +#### 2.1 New Files + +| File | Description | +|------|-------------| +| `app/templates/admin/inventory.html` | Main inventory page | +| `static/admin/js/inventory.js` | Alpine.js controller | + +#### 2.2 UI Features + +1. **Vendor Selector Dropdown** - Filter by vendor (or show all) +2. **Inventory Table** - Product, Location, Quantity, Reserved, Available +3. **Search/Filter** - By product name, location, low stock +4. **Adjust Modal** - Quick add/remove with reason +5. **Pagination** - Handle large inventories +6. **Export** - CSV download (future) + +#### 2.3 Page Layout + +``` +┌─────────────────────────────────────────────────────────┐ +│ Inventory Management [Vendor: All ▼] │ +├─────────────────────────────────────────────────────────┤ +│ [Search...] [Location ▼] [Low Stock Only ☐] │ +├─────────────────────────────────────────────────────────┤ +│ Product │ Location │ Qty │ Reserved │ Available │ +│──────────────┼─────────────┼─────┼──────────┼───────────│ +│ Widget A │ WAREHOUSE_A │ 100 │ 10 │ 90 [⚙️] │ +│ Widget A │ WAREHOUSE_B │ 50 │ 0 │ 50 [⚙️] │ +│ Gadget B │ WAREHOUSE_A │ 25 │ 5 │ 20 [⚙️] │ +├─────────────────────────────────────────────────────────┤ +│ Showing 1-20 of 150 [< 1 2 3 ... >] │ +└─────────────────────────────────────────────────────────┘ +``` + +#### 2.4 Sidebar Integration + +Add to `app/templates/admin/partials/sidebar.html`: + +```html + +
  • + + + + Inventory + + +
  • +``` + +### Phase 3: Audit Trail (Optional Enhancement) + +**Goal:** Track inventory changes with queryable history + +#### 3.1 Database Migration + +```sql +CREATE TABLE inventory_audit_log ( + id SERIAL PRIMARY KEY, + inventory_id INTEGER REFERENCES inventory(id) ON DELETE SET NULL, + product_id INTEGER NOT NULL, + vendor_id INTEGER NOT NULL, + location VARCHAR(255) NOT NULL, + + -- Change details + operation VARCHAR(50) NOT NULL, -- 'set', 'adjust', 'reserve', 'release', 'fulfill' + quantity_before INTEGER, + quantity_after INTEGER, + reserved_before INTEGER, + reserved_after INTEGER, + adjustment_amount INTEGER, + + -- Context + reason VARCHAR(500), + performed_by INTEGER REFERENCES users(id), + performed_by_type VARCHAR(20) NOT NULL, -- 'vendor', 'admin', 'system' + + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_audit_vendor ON inventory_audit_log(vendor_id); +CREATE INDEX idx_audit_product ON inventory_audit_log(product_id); +CREATE INDEX idx_audit_created ON inventory_audit_log(created_at); +``` + +#### 3.2 Service Enhancement + +Add audit logging to `InventoryService` methods: + +```python +def _log_audit( + self, + db: Session, + inventory: Inventory, + operation: str, + qty_before: int, + qty_after: int, + reserved_before: int, + reserved_after: int, + user_id: int | None, + user_type: str, + reason: str | None = None +) -> None: + """Record inventory change in audit log.""" + audit = InventoryAuditLog( + inventory_id=inventory.id, + product_id=inventory.product_id, + vendor_id=inventory.vendor_id, + location=inventory.location, + operation=operation, + quantity_before=qty_before, + quantity_after=qty_after, + reserved_before=reserved_before, + reserved_after=reserved_after, + adjustment_amount=qty_after - qty_before, + reason=reason, + performed_by=user_id, + performed_by_type=user_type, + ) + db.add(audit) +``` + +--- + +## Implementation Checklist + +### Phase 1: Admin API ✅ +- [x] Create `app/api/v1/admin/inventory.py` +- [x] Add admin inventory schemas to `models/schema/inventory.py` +- [x] Register routes in `app/api/v1/admin/__init__.py` +- [x] Write integration tests `tests/integration/api/v1/admin/test_inventory.py` + +### Phase 2: Admin UI +- [ ] Create `app/templates/admin/inventory.html` +- [ ] Create `static/admin/js/inventory.js` +- [ ] Add route in `app/routes/admin.py` +- [ ] Add sidebar menu item +- [ ] Update `static/admin/js/init-alpine.js` for page mapping + +### Phase 3: Audit Trail (Optional) +- [ ] Create Alembic migration for `inventory_audit_log` table +- [ ] Create `models/database/inventory_audit_log.py` +- [ ] Update `InventoryService` with audit logging +- [ ] Add audit history endpoint +- [ ] Add audit history UI component + +--- + +## Testing Strategy + +### Unit Tests +- Admin schema validation tests +- Audit log creation tests (if implemented) + +### Integration Tests +- Admin inventory endpoints with authentication +- Vendor isolation verification (admin can access any vendor) +- Audit trail creation on operations + +### Manual Testing +- Verify vendor selector works correctly +- Test adjust modal workflow +- Confirm pagination with large datasets + +--- + +## Rollback Plan + +Each phase is independent: + +1. **Phase 1 Rollback:** Remove admin inventory routes from `__init__.py` +2. **Phase 2 Rollback:** Remove sidebar link, delete template/JS files +3. **Phase 3 Rollback:** Run down migration to drop audit table + +--- + +## Dependencies + +- Existing `InventoryService` (no changes required) +- Admin authentication (`get_current_admin_api`) +- Vendor model for vendor selector dropdown + +--- + +## Related Documentation + +- [Vendor Operations Expansion Plan](../development/migration/vendor-operations-expansion.md) +- [Admin Integration Guide](../backend/admin-integration-guide.md) +- [Architecture Patterns](../architecture/architecture-patterns.md) diff --git a/models/schema/inventory.py b/models/schema/inventory.py index 1e33efad..9a673c05 100644 --- a/models/schema/inventory.py +++ b/models/schema/inventory.py @@ -96,3 +96,107 @@ class InventorySummaryResponse(BaseModel): gtin: str total_quantity: int locations: list[InventoryLocationResponse] + + +# ============================================================================ +# Admin Inventory Schemas +# ============================================================================ + + +class AdminInventoryCreate(BaseModel): + """Admin version of inventory create - requires explicit vendor_id.""" + + vendor_id: int = Field(..., description="Target vendor ID") + product_id: int = Field(..., description="Product ID in vendor catalog") + location: str = Field(..., description="Storage location") + quantity: int = Field(..., description="Exact inventory quantity", ge=0) + + +class AdminInventoryAdjust(BaseModel): + """Admin version of inventory adjust - requires explicit vendor_id.""" + + vendor_id: int = Field(..., description="Target vendor ID") + product_id: int = Field(..., description="Product ID in vendor catalog") + location: str = Field(..., description="Storage location") + quantity: int = Field( + ..., description="Quantity to add (positive) or remove (negative)" + ) + reason: str | None = Field(None, description="Reason for adjustment") + + +class AdminInventoryItem(BaseModel): + """Inventory item with vendor info for admin list view.""" + + model_config = ConfigDict(from_attributes=True) + + id: int + product_id: int + vendor_id: int + vendor_name: str | None = None + vendor_code: str | None = None + product_title: str | None = None + product_sku: str | None = None + location: str + quantity: int + reserved_quantity: int + available_quantity: int + gtin: str | None = None + created_at: datetime + updated_at: datetime + + +class AdminInventoryListResponse(BaseModel): + """Cross-vendor inventory list for admin.""" + + inventories: list[AdminInventoryItem] + total: int + skip: int + limit: int + vendor_filter: int | None = None + location_filter: str | None = None + + +class AdminInventoryStats(BaseModel): + """Inventory statistics for admin dashboard.""" + + total_entries: int + total_quantity: int + total_reserved: int + total_available: int + low_stock_count: int + vendors_with_inventory: int + unique_locations: int + + +class AdminLowStockItem(BaseModel): + """Low stock item for admin alerts.""" + + id: int + product_id: int + vendor_id: int + vendor_name: str | None = None + product_title: str | None = None + location: str + quantity: int + reserved_quantity: int + available_quantity: int + + +class AdminVendorWithInventory(BaseModel): + """Vendor with inventory entries.""" + + id: int + name: str + vendor_code: str + + +class AdminVendorsWithInventoryResponse(BaseModel): + """Response for vendors with inventory list.""" + + vendors: list[AdminVendorWithInventory] + + +class AdminInventoryLocationsResponse(BaseModel): + """Response for unique inventory locations.""" + + locations: list[str] diff --git a/static/admin/js/init-alpine.js b/static/admin/js/init-alpine.js index a612772e..b8a094e1 100644 --- a/static/admin/js/init-alpine.js +++ b/static/admin/js/init-alpine.js @@ -67,7 +67,9 @@ function data() { 'marketplace-products': 'vendorOps', 'vendor-products': 'vendorOps', customers: 'vendorOps', - // Future: inventory, orders, shipping will map to 'vendorOps' + inventory: 'vendorOps', + orders: 'vendorOps', + // Future: shipping will map to 'vendorOps' // Marketplace 'marketplace-letzshop': 'marketplace', // Content Management diff --git a/static/admin/js/inventory.js b/static/admin/js/inventory.js new file mode 100644 index 00000000..6d0fdd63 --- /dev/null +++ b/static/admin/js/inventory.js @@ -0,0 +1,426 @@ +// static/admin/js/inventory.js +/** + * Admin inventory management page logic + * View and manage stock levels across all vendors + */ + +const adminInventoryLog = window.LogConfig.loggers.adminInventory || + window.LogConfig.createLogger('adminInventory', false); + +adminInventoryLog.info('Loading...'); + +function adminInventory() { + adminInventoryLog.info('adminInventory() called'); + + return { + // Inherit base layout state + ...data(), + + // Set page identifier + currentPage: 'inventory', + + // Loading states + loading: true, + error: '', + saving: false, + + // Inventory data + inventory: [], + stats: { + total_entries: 0, + total_quantity: 0, + total_reserved: 0, + total_available: 0, + low_stock_count: 0, + vendors_with_inventory: 0, + unique_locations: 0 + }, + + // Filters + filters: { + search: '', + vendor_id: '', + location: '', + low_stock: '' + }, + + // Available locations for filter dropdown + locations: [], + + // Vendor selector controller (Tom Select) + vendorSelector: null, + + // Pagination + pagination: { + page: 1, + per_page: 50, + total: 0, + pages: 0 + }, + + // Modal states + showAdjustModal: false, + showSetModal: false, + showDeleteModal: false, + selectedItem: null, + + // Form data + adjustForm: { + quantity: 0, + reason: '' + }, + setForm: { + quantity: 0 + }, + + // Debounce timer + searchTimeout: null, + + // Computed: Total pages + get totalPages() { + return this.pagination.pages; + }, + + // Computed: Start index for pagination display + get startIndex() { + if (this.pagination.total === 0) return 0; + return (this.pagination.page - 1) * this.pagination.per_page + 1; + }, + + // Computed: End index for pagination display + get endIndex() { + const end = this.pagination.page * this.pagination.per_page; + return end > this.pagination.total ? this.pagination.total : end; + }, + + // Computed: Page numbers for pagination + get pageNumbers() { + const pages = []; + const totalPages = this.totalPages; + const current = this.pagination.page; + + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + if (current > 3) { + pages.push('...'); + } + const start = Math.max(2, current - 1); + const end = Math.min(totalPages - 1, current + 1); + for (let i = start; i <= end; i++) { + pages.push(i); + } + if (current < totalPages - 2) { + pages.push('...'); + } + pages.push(totalPages); + } + return pages; + }, + + async init() { + adminInventoryLog.info('Inventory init() called'); + + // Guard against multiple initialization + if (window._adminInventoryInitialized) { + adminInventoryLog.warn('Already initialized, skipping'); + return; + } + window._adminInventoryInitialized = true; + + // Initialize vendor selector (Tom Select) + this.$nextTick(() => { + this.initVendorSelector(); + }); + + // Load data in parallel + await Promise.all([ + this.loadStats(), + this.loadLocations(), + this.loadInventory() + ]); + + adminInventoryLog.info('Inventory initialization complete'); + }, + + /** + * Initialize vendor selector with Tom Select + */ + initVendorSelector() { + if (!this.$refs.vendorSelect) { + adminInventoryLog.warn('Vendor select element not found'); + return; + } + + this.vendorSelector = initVendorSelector(this.$refs.vendorSelect, { + placeholder: 'Filter by vendor...', + onSelect: (vendor) => { + adminInventoryLog.info('Vendor selected:', vendor); + this.filters.vendor_id = vendor.id; + this.pagination.page = 1; + this.loadLocations(); + this.loadInventory(); + }, + onClear: () => { + adminInventoryLog.info('Vendor filter cleared'); + this.filters.vendor_id = ''; + this.pagination.page = 1; + this.loadLocations(); + this.loadInventory(); + } + }); + }, + + /** + * Load inventory statistics + */ + async loadStats() { + try { + const response = await apiClient.get('/admin/inventory/stats'); + this.stats = response; + adminInventoryLog.info('Loaded stats:', this.stats); + } catch (error) { + adminInventoryLog.error('Failed to load stats:', error); + } + }, + + /** + * Load available locations for filter + */ + async loadLocations() { + try { + const params = this.filters.vendor_id ? `?vendor_id=${this.filters.vendor_id}` : ''; + const response = await apiClient.get(`/admin/inventory/locations${params}`); + this.locations = response.locations || []; + adminInventoryLog.info('Loaded locations:', this.locations.length); + } catch (error) { + adminInventoryLog.error('Failed to load locations:', error); + } + }, + + /** + * Load inventory with filtering and pagination + */ + async loadInventory() { + this.loading = true; + this.error = ''; + + try { + const params = new URLSearchParams({ + skip: (this.pagination.page - 1) * this.pagination.per_page, + limit: this.pagination.per_page + }); + + // Add filters + if (this.filters.search) { + params.append('search', this.filters.search); + } + if (this.filters.vendor_id) { + params.append('vendor_id', this.filters.vendor_id); + } + if (this.filters.location) { + params.append('location', this.filters.location); + } + if (this.filters.low_stock) { + params.append('low_stock', this.filters.low_stock); + } + + const response = await apiClient.get(`/admin/inventory?${params.toString()}`); + + this.inventory = response.items || []; + this.pagination.total = response.total || 0; + this.pagination.pages = Math.ceil(this.pagination.total / this.pagination.per_page); + + adminInventoryLog.info('Loaded inventory:', this.inventory.length, 'of', this.pagination.total); + } catch (error) { + adminInventoryLog.error('Failed to load inventory:', error); + this.error = error.message || 'Failed to load inventory'; + } finally { + this.loading = false; + } + }, + + /** + * Debounced search handler + */ + debouncedSearch() { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.pagination.page = 1; + this.loadInventory(); + }, 300); + }, + + /** + * Refresh inventory list + */ + async refresh() { + await Promise.all([ + this.loadStats(), + this.loadLocations(), + this.loadInventory() + ]); + }, + + /** + * Open adjust stock modal + */ + openAdjustModal(item) { + this.selectedItem = item; + this.adjustForm = { + quantity: 0, + reason: '' + }; + this.showAdjustModal = true; + }, + + /** + * Open set quantity modal + */ + openSetModal(item) { + this.selectedItem = item; + this.setForm = { + quantity: item.quantity + }; + this.showSetModal = true; + }, + + /** + * Confirm delete + */ + confirmDelete(item) { + this.selectedItem = item; + this.showDeleteModal = true; + }, + + /** + * Execute stock adjustment + */ + async executeAdjust() { + if (!this.selectedItem || this.adjustForm.quantity === 0) return; + + this.saving = true; + try { + await apiClient.post('/admin/inventory/adjust', { + vendor_id: this.selectedItem.vendor_id, + product_id: this.selectedItem.product_id, + location: this.selectedItem.location, + quantity: this.adjustForm.quantity, + reason: this.adjustForm.reason || null + }); + + adminInventoryLog.info('Adjusted inventory:', this.selectedItem.id); + + this.showAdjustModal = false; + this.selectedItem = null; + + Utils.showToast('Stock adjusted successfully.', 'success'); + + await this.refresh(); + } catch (error) { + adminInventoryLog.error('Failed to adjust inventory:', error); + Utils.showToast(error.message || 'Failed to adjust stock.', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Execute set quantity + */ + async executeSet() { + if (!this.selectedItem || this.setForm.quantity < 0) return; + + this.saving = true; + try { + await apiClient.post('/admin/inventory/set', { + vendor_id: this.selectedItem.vendor_id, + product_id: this.selectedItem.product_id, + location: this.selectedItem.location, + quantity: this.setForm.quantity + }); + + adminInventoryLog.info('Set inventory quantity:', this.selectedItem.id); + + this.showSetModal = false; + this.selectedItem = null; + + Utils.showToast('Quantity set successfully.', 'success'); + + await this.refresh(); + } catch (error) { + adminInventoryLog.error('Failed to set inventory:', error); + Utils.showToast(error.message || 'Failed to set quantity.', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Execute delete + */ + async executeDelete() { + if (!this.selectedItem) return; + + this.saving = true; + try { + await apiClient.delete(`/admin/inventory/${this.selectedItem.id}`); + + adminInventoryLog.info('Deleted inventory:', this.selectedItem.id); + + this.showDeleteModal = false; + this.selectedItem = null; + + Utils.showToast('Inventory entry deleted.', 'success'); + + await this.refresh(); + } catch (error) { + adminInventoryLog.error('Failed to delete inventory:', error); + Utils.showToast(error.message || 'Failed to delete entry.', 'error'); + } finally { + this.saving = false; + } + }, + + /** + * Format number with locale + */ + formatNumber(num) { + if (num === null || num === undefined) return '0'; + return new Intl.NumberFormat('en-US').format(num); + }, + + /** + * Pagination: Previous page + */ + previousPage() { + if (this.pagination.page > 1) { + this.pagination.page--; + this.loadInventory(); + } + }, + + /** + * Pagination: Next page + */ + nextPage() { + if (this.pagination.page < this.totalPages) { + this.pagination.page++; + this.loadInventory(); + } + }, + + /** + * Pagination: Go to specific page + */ + goToPage(pageNum) { + if (pageNum !== '...' && pageNum >= 1 && pageNum <= this.totalPages) { + this.pagination.page = pageNum; + this.loadInventory(); + } + } + }; +} diff --git a/tests/integration/api/v1/admin/test_inventory.py b/tests/integration/api/v1/admin/test_inventory.py new file mode 100644 index 00000000..4a43a266 --- /dev/null +++ b/tests/integration/api/v1/admin/test_inventory.py @@ -0,0 +1,497 @@ +# tests/integration/api/v1/admin/test_inventory.py +""" +Integration tests for admin inventory management endpoints. + +Tests the /api/v1/admin/inventory/* endpoints. +All endpoints require admin JWT authentication. +""" + +import pytest + + +@pytest.mark.integration +@pytest.mark.api +@pytest.mark.admin +@pytest.mark.inventory +class TestAdminInventoryAPI: + """Tests for admin inventory management endpoints.""" + + # ======================================================================== + # List & Statistics Tests + # ======================================================================== + + def test_get_all_inventory_admin( + self, client, admin_headers, test_inventory, test_vendor + ): + """Test admin getting all inventory.""" + response = client.get("/api/v1/admin/inventory", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert "inventories" in data + assert "total" in data + assert "skip" in data + assert "limit" in data + assert data["total"] >= 1 + assert len(data["inventories"]) >= 1 + + # Check that test inventory is in the response + inventory_ids = [i["id"] for i in data["inventories"]] + assert test_inventory.id in inventory_ids + + def test_get_all_inventory_non_admin(self, client, auth_headers): + """Test non-admin trying to access inventory endpoint.""" + response = client.get("/api/v1/admin/inventory", headers=auth_headers) + + assert response.status_code == 403 + data = response.json() + assert data["error_code"] == "ADMIN_REQUIRED" + + def test_get_all_inventory_with_vendor_filter( + self, client, admin_headers, test_inventory, test_vendor + ): + """Test admin filtering inventory by vendor.""" + response = client.get( + "/api/v1/admin/inventory", + params={"vendor_id": test_vendor.id}, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + assert data["vendor_filter"] == test_vendor.id + # All inventory should be from the filtered vendor + for item in data["inventories"]: + assert item["vendor_id"] == test_vendor.id + + def test_get_all_inventory_with_location_filter( + self, client, admin_headers, test_inventory + ): + """Test admin filtering inventory by location.""" + location = test_inventory.location[:5] # Partial match + response = client.get( + "/api/v1/admin/inventory", + params={"location": location}, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + # All items should have matching location + for item in data["inventories"]: + assert location.upper() in item["location"].upper() + + def test_get_all_inventory_with_low_stock_filter( + self, client, admin_headers, test_inventory, db + ): + """Test admin filtering inventory by low stock threshold.""" + # Set test inventory to low stock + test_inventory.quantity = 5 + db.commit() + + response = client.get( + "/api/v1/admin/inventory", + params={"low_stock": 10}, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + # All items should have quantity <= threshold + for item in data["inventories"]: + assert item["quantity"] <= 10 + + def test_get_all_inventory_pagination( + self, client, admin_headers, test_inventory + ): + """Test admin inventory pagination.""" + response = client.get( + "/api/v1/admin/inventory", + params={"skip": 0, "limit": 10}, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["skip"] == 0 + assert data["limit"] == 10 + + def test_get_inventory_stats_admin( + self, client, admin_headers, test_inventory + ): + """Test admin getting inventory statistics.""" + response = client.get( + "/api/v1/admin/inventory/stats", headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert "total_entries" in data + assert "total_quantity" in data + assert "total_reserved" in data + assert "total_available" in data + assert "low_stock_count" in data + assert "vendors_with_inventory" in data + assert "unique_locations" in data + assert data["total_entries"] >= 1 + + def test_get_inventory_stats_non_admin(self, client, auth_headers): + """Test non-admin trying to access inventory stats.""" + response = client.get( + "/api/v1/admin/inventory/stats", headers=auth_headers + ) + + assert response.status_code == 403 + + def test_get_low_stock_items_admin( + self, client, admin_headers, test_inventory, db + ): + """Test admin getting low stock items.""" + # Set test inventory to low stock + test_inventory.quantity = 3 + db.commit() + + response = client.get( + "/api/v1/admin/inventory/low-stock", + params={"threshold": 10}, + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + # All items should have quantity <= threshold + for item in data: + assert item["quantity"] <= 10 + + def test_get_vendors_with_inventory_admin( + self, client, admin_headers, test_inventory, test_vendor + ): + """Test admin getting vendors with inventory.""" + response = client.get( + "/api/v1/admin/inventory/vendors", headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert "vendors" in data + assert isinstance(data["vendors"], list) + assert len(data["vendors"]) >= 1 + + # Check that test_vendor is in the list + vendor_ids = [v["id"] for v in data["vendors"]] + assert test_vendor.id in vendor_ids + + def test_get_inventory_locations_admin( + self, client, admin_headers, test_inventory + ): + """Test admin getting inventory locations.""" + response = client.get( + "/api/v1/admin/inventory/locations", headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert "locations" in data + assert isinstance(data["locations"], list) + assert len(data["locations"]) >= 1 + assert test_inventory.location in data["locations"] + + # ======================================================================== + # Vendor-Specific Tests + # ======================================================================== + + def test_get_vendor_inventory_admin( + self, client, admin_headers, test_inventory, test_vendor + ): + """Test admin getting vendor-specific inventory.""" + response = client.get( + f"/api/v1/admin/inventory/vendors/{test_vendor.id}", + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert "inventories" in data + assert "total" in data + assert data["vendor_filter"] == test_vendor.id + assert data["total"] >= 1 + + # All inventory should be from this vendor + for item in data["inventories"]: + assert item["vendor_id"] == test_vendor.id + + def test_get_vendor_inventory_not_found(self, client, admin_headers): + """Test admin getting inventory for non-existent vendor.""" + response = client.get( + "/api/v1/admin/inventory/vendors/99999", + headers=admin_headers, + ) + + assert response.status_code == 404 + data = response.json() + assert data["error_code"] == "VENDOR_NOT_FOUND" + + def test_get_product_inventory_admin( + self, client, admin_headers, test_inventory, test_product + ): + """Test admin getting product inventory summary.""" + response = client.get( + f"/api/v1/admin/inventory/products/{test_product.id}", + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["product_id"] == test_product.id + assert "total_quantity" in data + assert "total_reserved" in data + assert "total_available" in data + assert "locations" in data + + def test_get_product_inventory_not_found(self, client, admin_headers): + """Test admin getting inventory for non-existent product.""" + response = client.get( + "/api/v1/admin/inventory/products/99999", + headers=admin_headers, + ) + + assert response.status_code == 404 + data = response.json() + assert data["error_code"] == "PRODUCT_NOT_FOUND" + + # ======================================================================== + # Inventory Modification Tests + # ======================================================================== + + def test_set_inventory_admin( + self, client, admin_headers, test_product, test_vendor + ): + """Test admin setting inventory for a product.""" + inventory_data = { + "vendor_id": test_vendor.id, + "product_id": test_product.id, + "location": "ADMIN_TEST_WAREHOUSE", + "quantity": 150, + } + + response = client.post( + "/api/v1/admin/inventory/set", + headers=admin_headers, + json=inventory_data, + ) + + assert response.status_code == 200, f"Failed: {response.json()}" + data = response.json() + assert data["product_id"] == test_product.id + assert data["vendor_id"] == test_vendor.id + assert data["quantity"] == 150 + assert data["location"] == "ADMIN_TEST_WAREHOUSE" + + def test_set_inventory_non_admin( + self, client, auth_headers, test_product, test_vendor + ): + """Test non-admin trying to set inventory.""" + inventory_data = { + "vendor_id": test_vendor.id, + "product_id": test_product.id, + "location": "WAREHOUSE_A", + "quantity": 100, + } + + response = client.post( + "/api/v1/admin/inventory/set", + headers=auth_headers, + json=inventory_data, + ) + + assert response.status_code == 403 + + def test_set_inventory_vendor_not_found( + self, client, admin_headers, test_product + ): + """Test admin setting inventory for non-existent vendor.""" + inventory_data = { + "vendor_id": 99999, + "product_id": test_product.id, + "location": "WAREHOUSE_A", + "quantity": 100, + } + + response = client.post( + "/api/v1/admin/inventory/set", + headers=admin_headers, + json=inventory_data, + ) + + assert response.status_code == 404 + data = response.json() + assert data["error_code"] == "VENDOR_NOT_FOUND" + + def test_adjust_inventory_add_admin( + self, client, admin_headers, test_inventory, test_vendor, test_product + ): + """Test admin adding to inventory.""" + original_qty = test_inventory.quantity + + adjust_data = { + "vendor_id": test_vendor.id, + "product_id": test_product.id, + "location": test_inventory.location, + "quantity": 25, + "reason": "Admin restocking", + } + + response = client.post( + "/api/v1/admin/inventory/adjust", + headers=admin_headers, + json=adjust_data, + ) + + assert response.status_code == 200 + data = response.json() + assert data["quantity"] == original_qty + 25 + + def test_adjust_inventory_remove_admin( + self, client, admin_headers, test_inventory, test_vendor, test_product, db + ): + """Test admin removing from inventory.""" + # Ensure we have enough inventory + test_inventory.quantity = 100 + db.commit() + + adjust_data = { + "vendor_id": test_vendor.id, + "product_id": test_product.id, + "location": test_inventory.location, + "quantity": -10, + "reason": "Admin adjustment", + } + + response = client.post( + "/api/v1/admin/inventory/adjust", + headers=admin_headers, + json=adjust_data, + ) + + assert response.status_code == 200 + data = response.json() + assert data["quantity"] == 90 + + def test_adjust_inventory_insufficient( + self, client, admin_headers, test_inventory, test_vendor, test_product, db + ): + """Test admin trying to remove more than available.""" + # Set low inventory + test_inventory.quantity = 5 + db.commit() + + adjust_data = { + "vendor_id": test_vendor.id, + "product_id": test_product.id, + "location": test_inventory.location, + "quantity": -100, + } + + response = client.post( + "/api/v1/admin/inventory/adjust", + headers=admin_headers, + json=adjust_data, + ) + + # Service wraps InsufficientInventoryException in ValidationException + assert response.status_code == 422 + data = response.json() + # Error is wrapped - check message contains relevant info + assert "error_code" in data + assert "insufficient" in data.get("message", "").lower() or data["error_code"] in [ + "INSUFFICIENT_INVENTORY", + "VALIDATION_ERROR", + ] + + def test_update_inventory_admin( + self, client, admin_headers, test_inventory + ): + """Test admin updating inventory entry.""" + update_data = { + "quantity": 200, + } + + response = client.put( + f"/api/v1/admin/inventory/{test_inventory.id}", + headers=admin_headers, + json=update_data, + ) + + assert response.status_code == 200 + data = response.json() + assert data["quantity"] == 200 + + def test_update_inventory_not_found(self, client, admin_headers): + """Test admin updating non-existent inventory.""" + update_data = {"quantity": 100} + + response = client.put( + "/api/v1/admin/inventory/99999", + headers=admin_headers, + json=update_data, + ) + + assert response.status_code == 404 + data = response.json() + assert data["error_code"] == "INVENTORY_NOT_FOUND" + + def test_delete_inventory_admin( + self, client, admin_headers, test_product, test_vendor, db + ): + """Test admin deleting inventory entry.""" + # Create a new inventory entry to delete + from models.database.inventory import Inventory + + new_inventory = Inventory( + product_id=test_product.id, + vendor_id=test_vendor.id, + location="TO_DELETE_WAREHOUSE", + quantity=50, + ) + db.add(new_inventory) + db.commit() + db.refresh(new_inventory) + inventory_id = new_inventory.id + + response = client.delete( + f"/api/v1/admin/inventory/{inventory_id}", + headers=admin_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert "message" in data + assert "deleted" in data["message"].lower() + + # Verify it's deleted + deleted = db.query(Inventory).filter(Inventory.id == inventory_id).first() + assert deleted is None + + def test_delete_inventory_not_found(self, client, admin_headers): + """Test admin deleting non-existent inventory.""" + response = client.delete( + "/api/v1/admin/inventory/99999", + headers=admin_headers, + ) + + assert response.status_code == 404 + data = response.json() + assert data["error_code"] == "INVENTORY_NOT_FOUND" + + def test_delete_inventory_non_admin( + self, client, auth_headers, test_inventory + ): + """Test non-admin trying to delete inventory.""" + response = client.delete( + f"/api/v1/admin/inventory/{test_inventory.id}", + headers=auth_headers, + ) + + assert response.status_code == 403