# app/services/inventory_service.py import logging from datetime import UTC, datetime from sqlalchemy.orm import Session from app.exceptions import ( InsufficientInventoryException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, ProductNotFoundException, ValidationException, ) from models.database.inventory import Inventory from models.database.product import Product 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(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, location=location, 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, location=location, 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") # 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()