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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user