Database & Migrations: - Update all Alembic migrations for PostgreSQL compatibility - Remove SQLite-specific syntax (AUTOINCREMENT, etc.) - Add database utility helpers for PostgreSQL operations - Fix services to use PostgreSQL-compatible queries Documentation: - Add comprehensive Docker deployment guide - Add production deployment documentation - Add infrastructure architecture documentation - Update database setup guide for PostgreSQL-only - Expand troubleshooting guide Architecture & Validation: - Add migration.yaml rules for SQL compatibility checking - Enhance validate_architecture.py with migration validation - Update architecture rules to validate Alembic migrations Development: - Fix duplicate install-all target in Makefile - Add Celery/Redis validation to install.py script - Add docker-compose.test.yml for CI testing - Add squash_migrations.py utility script - Update tests for PostgreSQL compatibility - Improve test fixtures in conftest.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
950 lines
33 KiB
Python
950 lines
33 KiB
Python
# app/services/inventory_service.py
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.exceptions import (
|
|
InsufficientInventoryException,
|
|
InvalidQuantityException,
|
|
InventoryNotFoundException,
|
|
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,
|
|
InventoryReserve,
|
|
InventoryUpdate,
|
|
ProductInventorySummary,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class InventoryService:
|
|
"""Service for inventory operations with vendor isolation."""
|
|
|
|
def set_inventory(
|
|
self, db: Session, vendor_id: int, inventory_data: InventoryCreate
|
|
) -> Inventory:
|
|
"""
|
|
Set exact inventory quantity for a product at a location (replaces existing).
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID (from middleware)
|
|
inventory_data: Inventory data
|
|
|
|
Returns:
|
|
Inventory object
|
|
"""
|
|
try:
|
|
# Validate product belongs to vendor
|
|
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
|
|
|
|
# Validate location
|
|
location = self._validate_location(inventory_data.location)
|
|
|
|
# Validate quantity
|
|
self._validate_quantity(inventory_data.quantity, allow_zero=True)
|
|
|
|
# Check if inventory entry exists
|
|
existing = self._get_inventory_entry(
|
|
db, inventory_data.product_id, location
|
|
)
|
|
|
|
if existing:
|
|
old_qty = existing.quantity
|
|
existing.quantity = inventory_data.quantity
|
|
existing.updated_at = datetime.now(UTC)
|
|
db.flush()
|
|
db.refresh(existing)
|
|
|
|
logger.info(
|
|
f"Set inventory for product {inventory_data.product_id} at {location}: "
|
|
f"{old_qty} → {inventory_data.quantity}"
|
|
)
|
|
return existing
|
|
# Create new inventory entry
|
|
new_inventory = Inventory(
|
|
product_id=inventory_data.product_id,
|
|
vendor_id=vendor_id,
|
|
warehouse="strassen", # Default warehouse
|
|
bin_location=location, # Use location as bin location
|
|
location=location, # Keep for backward compatibility
|
|
quantity=inventory_data.quantity,
|
|
gtin=product.marketplace_product.gtin, # Optional reference
|
|
)
|
|
db.add(new_inventory)
|
|
db.flush()
|
|
db.refresh(new_inventory)
|
|
|
|
logger.info(
|
|
f"Created inventory for product {inventory_data.product_id} at {location}: "
|
|
f"{inventory_data.quantity}"
|
|
)
|
|
return new_inventory
|
|
|
|
except (
|
|
ProductNotFoundException,
|
|
InvalidQuantityException,
|
|
InventoryValidationException,
|
|
):
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error setting inventory: {str(e)}")
|
|
raise ValidationException("Failed to set inventory")
|
|
|
|
def adjust_inventory(
|
|
self, db: Session, vendor_id: int, inventory_data: InventoryAdjust
|
|
) -> Inventory:
|
|
"""
|
|
Adjust inventory by adding or removing quantity.
|
|
Positive quantity = add, negative = remove.
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID
|
|
inventory_data: Adjustment data
|
|
|
|
Returns:
|
|
Updated Inventory object
|
|
"""
|
|
try:
|
|
# Validate product belongs to vendor
|
|
product = self._get_vendor_product(db, vendor_id, inventory_data.product_id)
|
|
|
|
# Validate location
|
|
location = self._validate_location(inventory_data.location)
|
|
|
|
# Check if inventory exists
|
|
existing = self._get_inventory_entry(
|
|
db, inventory_data.product_id, location
|
|
)
|
|
|
|
if not existing:
|
|
# Create new if adding, error if removing
|
|
if inventory_data.quantity < 0:
|
|
raise InventoryNotFoundException(
|
|
f"No inventory found for product {inventory_data.product_id} at {location}"
|
|
)
|
|
|
|
# Create with positive quantity
|
|
new_inventory = Inventory(
|
|
product_id=inventory_data.product_id,
|
|
vendor_id=vendor_id,
|
|
warehouse="strassen", # Default warehouse
|
|
bin_location=location, # Use location as bin location
|
|
location=location, # Keep for backward compatibility
|
|
quantity=inventory_data.quantity,
|
|
gtin=product.marketplace_product.gtin,
|
|
)
|
|
db.add(new_inventory)
|
|
db.flush()
|
|
db.refresh(new_inventory)
|
|
|
|
logger.info(
|
|
f"Created inventory for product {inventory_data.product_id} at {location}: "
|
|
f"+{inventory_data.quantity}"
|
|
)
|
|
return new_inventory
|
|
|
|
# Adjust existing inventory
|
|
old_qty = existing.quantity
|
|
new_qty = old_qty + inventory_data.quantity
|
|
|
|
# Validate resulting quantity
|
|
if new_qty < 0:
|
|
raise InsufficientInventoryException(
|
|
f"Insufficient inventory. Available: {old_qty}, "
|
|
f"Requested removal: {abs(inventory_data.quantity)}"
|
|
)
|
|
|
|
existing.quantity = new_qty
|
|
existing.updated_at = datetime.now(UTC)
|
|
db.flush()
|
|
db.refresh(existing)
|
|
|
|
logger.info(
|
|
f"Adjusted inventory for product {inventory_data.product_id} at {location}: "
|
|
f"{old_qty} {'+' if inventory_data.quantity >= 0 else ''}{inventory_data.quantity} = {new_qty}"
|
|
)
|
|
return existing
|
|
|
|
except (
|
|
ProductNotFoundException,
|
|
InventoryNotFoundException,
|
|
InsufficientInventoryException,
|
|
InventoryValidationException,
|
|
):
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error adjusting inventory: {str(e)}")
|
|
raise ValidationException("Failed to adjust inventory")
|
|
|
|
def reserve_inventory(
|
|
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
|
|
) -> Inventory:
|
|
"""
|
|
Reserve inventory for an order (increases reserved_quantity).
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID
|
|
reserve_data: Reservation data
|
|
|
|
Returns:
|
|
Updated Inventory object
|
|
"""
|
|
try:
|
|
# Validate product
|
|
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
|
|
|
|
# Validate location and quantity
|
|
location = self._validate_location(reserve_data.location)
|
|
self._validate_quantity(reserve_data.quantity, allow_zero=False)
|
|
|
|
# Get inventory entry
|
|
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
|
|
if not inventory:
|
|
raise InventoryNotFoundException(
|
|
f"No inventory found for product {reserve_data.product_id} at {location}"
|
|
)
|
|
|
|
# Check available quantity
|
|
available = inventory.quantity - inventory.reserved_quantity
|
|
if available < reserve_data.quantity:
|
|
raise InsufficientInventoryException(
|
|
f"Insufficient available inventory. Available: {available}, "
|
|
f"Requested: {reserve_data.quantity}"
|
|
)
|
|
|
|
# Reserve inventory
|
|
inventory.reserved_quantity += reserve_data.quantity
|
|
inventory.updated_at = datetime.now(UTC)
|
|
db.flush()
|
|
db.refresh(inventory)
|
|
|
|
logger.info(
|
|
f"Reserved {reserve_data.quantity} units for product {reserve_data.product_id} "
|
|
f"at {location}"
|
|
)
|
|
return inventory
|
|
|
|
except (
|
|
ProductNotFoundException,
|
|
InventoryNotFoundException,
|
|
InsufficientInventoryException,
|
|
InvalidQuantityException,
|
|
):
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error reserving inventory: {str(e)}")
|
|
raise ValidationException("Failed to reserve inventory")
|
|
|
|
def release_reservation(
|
|
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
|
|
) -> Inventory:
|
|
"""
|
|
Release reserved inventory (decreases reserved_quantity).
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID
|
|
reserve_data: Reservation data
|
|
|
|
Returns:
|
|
Updated Inventory object
|
|
"""
|
|
try:
|
|
# Validate product
|
|
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
|
|
|
|
location = self._validate_location(reserve_data.location)
|
|
self._validate_quantity(reserve_data.quantity, allow_zero=False)
|
|
|
|
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
|
|
if not inventory:
|
|
raise InventoryNotFoundException(
|
|
f"No inventory found for product {reserve_data.product_id} at {location}"
|
|
)
|
|
|
|
# Validate reserved quantity
|
|
if inventory.reserved_quantity < reserve_data.quantity:
|
|
logger.warning(
|
|
f"Attempting to release more than reserved. Reserved: {inventory.reserved_quantity}, "
|
|
f"Requested: {reserve_data.quantity}"
|
|
)
|
|
inventory.reserved_quantity = 0
|
|
else:
|
|
inventory.reserved_quantity -= reserve_data.quantity
|
|
|
|
inventory.updated_at = datetime.now(UTC)
|
|
db.flush()
|
|
db.refresh(inventory)
|
|
|
|
logger.info(
|
|
f"Released {reserve_data.quantity} units for product {reserve_data.product_id} "
|
|
f"at {location}"
|
|
)
|
|
return inventory
|
|
|
|
except (
|
|
ProductNotFoundException,
|
|
InventoryNotFoundException,
|
|
InvalidQuantityException,
|
|
):
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error releasing reservation: {str(e)}")
|
|
raise ValidationException("Failed to release reservation")
|
|
|
|
def fulfill_reservation(
|
|
self, db: Session, vendor_id: int, reserve_data: InventoryReserve
|
|
) -> Inventory:
|
|
"""
|
|
Fulfill a reservation (decreases both quantity and reserved_quantity).
|
|
Use when order is shipped/completed.
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID
|
|
reserve_data: Reservation data
|
|
|
|
Returns:
|
|
Updated Inventory object
|
|
"""
|
|
try:
|
|
product = self._get_vendor_product(db, vendor_id, reserve_data.product_id)
|
|
location = self._validate_location(reserve_data.location)
|
|
self._validate_quantity(reserve_data.quantity, allow_zero=False)
|
|
|
|
inventory = self._get_inventory_entry(db, reserve_data.product_id, location)
|
|
if not inventory:
|
|
raise InventoryNotFoundException(
|
|
f"No inventory found for product {reserve_data.product_id} at {location}"
|
|
)
|
|
|
|
# Validate quantities
|
|
if inventory.quantity < reserve_data.quantity:
|
|
raise InsufficientInventoryException(
|
|
f"Insufficient inventory. Available: {inventory.quantity}, "
|
|
f"Requested: {reserve_data.quantity}"
|
|
)
|
|
|
|
if inventory.reserved_quantity < reserve_data.quantity:
|
|
logger.warning(
|
|
f"Fulfilling more than reserved. Reserved: {inventory.reserved_quantity}, "
|
|
f"Fulfilling: {reserve_data.quantity}"
|
|
)
|
|
|
|
# Fulfill (remove from both quantity and reserved)
|
|
inventory.quantity -= reserve_data.quantity
|
|
inventory.reserved_quantity = max(
|
|
0, inventory.reserved_quantity - reserve_data.quantity
|
|
)
|
|
inventory.updated_at = datetime.now(UTC)
|
|
db.flush()
|
|
db.refresh(inventory)
|
|
|
|
logger.info(
|
|
f"Fulfilled {reserve_data.quantity} units for product {reserve_data.product_id} "
|
|
f"at {location}"
|
|
)
|
|
return inventory
|
|
|
|
except (
|
|
ProductNotFoundException,
|
|
InventoryNotFoundException,
|
|
InsufficientInventoryException,
|
|
InvalidQuantityException,
|
|
):
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error fulfilling reservation: {str(e)}")
|
|
raise ValidationException("Failed to fulfill reservation")
|
|
|
|
def get_product_inventory(
|
|
self, db: Session, vendor_id: int, product_id: int
|
|
) -> ProductInventorySummary:
|
|
"""
|
|
Get inventory summary for a product across all locations.
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID
|
|
product_id: Product ID
|
|
|
|
Returns:
|
|
ProductInventorySummary
|
|
"""
|
|
try:
|
|
product = self._get_vendor_product(db, vendor_id, product_id)
|
|
|
|
inventory_entries = (
|
|
db.query(Inventory).filter(Inventory.product_id == product_id).all()
|
|
)
|
|
|
|
if not inventory_entries:
|
|
return ProductInventorySummary(
|
|
product_id=product_id,
|
|
vendor_id=vendor_id,
|
|
product_sku=product.vendor_sku,
|
|
product_title=product.marketplace_product.get_title() or "",
|
|
total_quantity=0,
|
|
total_reserved=0,
|
|
total_available=0,
|
|
locations=[],
|
|
)
|
|
|
|
total_qty = sum(inv.quantity for inv in inventory_entries)
|
|
total_reserved = sum(inv.reserved_quantity for inv in inventory_entries)
|
|
total_available = sum(inv.available_quantity for inv in inventory_entries)
|
|
|
|
locations = [
|
|
InventoryLocationResponse(
|
|
location=inv.location,
|
|
quantity=inv.quantity,
|
|
reserved_quantity=inv.reserved_quantity,
|
|
available_quantity=inv.available_quantity,
|
|
)
|
|
for inv in inventory_entries
|
|
]
|
|
|
|
return ProductInventorySummary(
|
|
product_id=product_id,
|
|
vendor_id=vendor_id,
|
|
product_sku=product.vendor_sku,
|
|
product_title=product.marketplace_product.get_title() or "",
|
|
total_quantity=total_qty,
|
|
total_reserved=total_reserved,
|
|
total_available=total_available,
|
|
locations=locations,
|
|
)
|
|
|
|
except ProductNotFoundException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error getting product inventory: {str(e)}")
|
|
raise ValidationException("Failed to retrieve product inventory")
|
|
|
|
def get_vendor_inventory(
|
|
self,
|
|
db: Session,
|
|
vendor_id: int,
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
location: str | None = None,
|
|
low_stock_threshold: int | None = None,
|
|
) -> list[Inventory]:
|
|
"""
|
|
Get all inventory for a vendor with filtering.
|
|
|
|
Args:
|
|
db: Database session
|
|
vendor_id: Vendor ID
|
|
skip: Pagination offset
|
|
limit: Pagination limit
|
|
location: Filter by location
|
|
low_stock_threshold: Filter items below threshold
|
|
|
|
Returns:
|
|
List of Inventory objects
|
|
"""
|
|
try:
|
|
query = db.query(Inventory).filter(Inventory.vendor_id == vendor_id)
|
|
|
|
if location:
|
|
query = query.filter(Inventory.location.ilike(f"%{location}%"))
|
|
|
|
if low_stock_threshold is not None:
|
|
query = query.filter(Inventory.quantity <= low_stock_threshold)
|
|
|
|
return query.offset(skip).limit(limit).all()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting vendor inventory: {str(e)}")
|
|
raise ValidationException("Failed to retrieve vendor inventory")
|
|
|
|
def update_inventory(
|
|
self,
|
|
db: Session,
|
|
vendor_id: int,
|
|
inventory_id: int,
|
|
inventory_update: InventoryUpdate,
|
|
) -> Inventory:
|
|
"""Update inventory entry."""
|
|
try:
|
|
inventory = self._get_inventory_by_id(db, inventory_id)
|
|
|
|
# Verify ownership
|
|
if inventory.vendor_id != vendor_id:
|
|
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
|
|
|
|
# Update fields
|
|
if inventory_update.quantity is not None:
|
|
self._validate_quantity(inventory_update.quantity, allow_zero=True)
|
|
inventory.quantity = inventory_update.quantity
|
|
|
|
if inventory_update.reserved_quantity is not None:
|
|
self._validate_quantity(
|
|
inventory_update.reserved_quantity, allow_zero=True
|
|
)
|
|
inventory.reserved_quantity = inventory_update.reserved_quantity
|
|
|
|
if inventory_update.location:
|
|
inventory.location = self._validate_location(inventory_update.location)
|
|
|
|
inventory.updated_at = datetime.now(UTC)
|
|
db.flush()
|
|
db.refresh(inventory)
|
|
|
|
logger.info(f"Updated inventory {inventory_id}")
|
|
return inventory
|
|
|
|
except (
|
|
InventoryNotFoundException,
|
|
InvalidQuantityException,
|
|
InventoryValidationException,
|
|
):
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f"Error updating inventory: {str(e)}")
|
|
raise ValidationException("Failed to update inventory")
|
|
|
|
def delete_inventory(self, db: Session, vendor_id: int, inventory_id: int) -> bool:
|
|
"""Delete inventory entry."""
|
|
try:
|
|
inventory = self._get_inventory_by_id(db, inventory_id)
|
|
|
|
# Verify ownership
|
|
if inventory.vendor_id != vendor_id:
|
|
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
|
|
|
|
db.delete(inventory)
|
|
db.flush()
|
|
|
|
logger.info(f"Deleted inventory {inventory_id}")
|
|
return True
|
|
|
|
except InventoryNotFoundException:
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
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)."""
|
|
# noqa: SVC-005 - Admin function, intentionally cross-vendor
|
|
# Use subquery to avoid DISTINCT on JSON columns (PostgreSQL can't compare JSON)
|
|
vendor_ids_subquery = (
|
|
db.query(Inventory.vendor_id)
|
|
.distinct()
|
|
.subquery()
|
|
)
|
|
vendors = (
|
|
db.query(Vendor)
|
|
.filter(Vendor.id.in_(db.query(vendor_ids_subquery.c.vendor_id)))
|
|
.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:
|
|
"""Get product and verify it belongs to vendor."""
|
|
product = (
|
|
db.query(Product)
|
|
.filter(Product.id == product_id, Product.vendor_id == vendor_id)
|
|
.first()
|
|
)
|
|
|
|
if not product:
|
|
raise ProductNotFoundException(
|
|
f"Product {product_id} not found in your catalog"
|
|
)
|
|
|
|
return product
|
|
|
|
def _get_inventory_entry(
|
|
self, db: Session, product_id: int, location: str
|
|
) -> Inventory | None:
|
|
"""Get inventory entry by product and location."""
|
|
return (
|
|
db.query(Inventory)
|
|
.filter(Inventory.product_id == product_id, Inventory.location == location)
|
|
.first()
|
|
)
|
|
|
|
def _get_inventory_by_id(self, db: Session, inventory_id: int) -> Inventory:
|
|
"""Get inventory by ID or raise exception."""
|
|
inventory = db.query(Inventory).filter(Inventory.id == inventory_id).first()
|
|
if not inventory:
|
|
raise InventoryNotFoundException(f"Inventory {inventory_id} not found")
|
|
return inventory
|
|
|
|
def _validate_location(self, location: str) -> str:
|
|
"""Validate and normalize location."""
|
|
if not location or not location.strip():
|
|
raise InventoryValidationException("Location is required")
|
|
return location.strip().upper()
|
|
|
|
def _validate_quantity(self, quantity: int, allow_zero: bool = True) -> None:
|
|
"""Validate quantity value."""
|
|
if quantity is None:
|
|
raise InvalidQuantityException("Quantity is required")
|
|
|
|
if not isinstance(quantity, int):
|
|
raise InvalidQuantityException("Quantity must be an integer")
|
|
|
|
if quantity < 0:
|
|
raise InvalidQuantityException("Quantity cannot be negative")
|
|
|
|
if not allow_zero and quantity == 0:
|
|
raise InvalidQuantityException("Quantity must be positive")
|
|
|
|
|
|
# Create service instance
|
|
inventory_service = InventoryService()
|