# app/services/inventory_service.py import logging from datetime import datetime, timezone from typing import List, Optional from sqlalchemy.orm import Session from app.exceptions import (InsufficientInventoryException, InvalidInventoryOperationException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, NegativeInventoryException, ProductNotFoundException, ValidationException) from models.database.inventory import Inventory from models.database.product import Product from models.database.vendor import Vendor from models.schema.inventory import (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(timezone.utc) db.commit() db.refresh(existing) logger.info( f"Set inventory for product {inventory_data.product_id} at {location}: " f"{old_qty} → {inventory_data.quantity}" ) return existing else: # Create new inventory entry new_inventory = Inventory( product_id=inventory_data.product_id, vendor_id=vendor_id, location=location, quantity=inventory_data.quantity, gtin=product.marketplace_product.gtin, # Optional reference ) db.add(new_inventory) db.commit() 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, location=location, quantity=inventory_data.quantity, gtin=product.marketplace_product.gtin, ) db.add(new_inventory) db.commit() 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(timezone.utc) db.commit() 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(timezone.utc) db.commit() 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(timezone.utc) db.commit() 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(timezone.utc) db.commit() 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.product_id, product_title=product.marketplace_product.title, 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.product_id, product_title=product.marketplace_product.title, 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: Optional[str] = None, low_stock_threshold: Optional[int] = 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(timezone.utc) db.commit() 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.commit() 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") # 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 ) -> Optional[Inventory]: """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()