# app/services/stock_service.py """ Stock service for managing inventory operations. This module provides classes and functions for: - Stock quantity management (set, add, remove) - Stock information retrieval and validation - Location-based inventory tracking - GTIN normalization and validation """ import logging from datetime import datetime, timezone from typing import List, Optional from sqlalchemy.orm import Session from app.exceptions import ( StockNotFoundException, InsufficientStockException, InvalidStockOperationException, StockValidationException, NegativeStockException, InvalidQuantityException, ValidationException, ) from models.schemas.stock import (StockAdd, StockCreate, StockLocationResponse, StockSummaryResponse, StockUpdate) from models.database.product import Product from models.database.stock import Stock from app.utils.data_processing import GTINProcessor logger = logging.getLogger(__name__) class StockService: """Service class for stock operations following the application's service pattern.""" def __init__(self): """Class constructor.""" self.gtin_processor = GTINProcessor() def set_stock(self, db: Session, stock_data: StockCreate) -> Stock: """ Set exact stock quantity for a GTIN at a specific location (replaces existing quantity). Args: db: Database session stock_data: Stock creation data Returns: Stock object with updated quantity Raises: InvalidQuantityException: If quantity is negative StockValidationException: If GTIN or location is invalid """ try: # Validate and normalize input normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin) location = self._validate_and_normalize_location(stock_data.location) self._validate_quantity(stock_data.quantity, allow_zero=True) # Check if stock entry already exists for this GTIN and location existing_stock = self._get_stock_entry(db, normalized_gtin, location) if existing_stock: # Update existing stock (SET to exact quantity) old_quantity = existing_stock.quantity existing_stock.quantity = stock_data.quantity existing_stock.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(existing_stock) logger.info( f"Updated stock for GTIN {normalized_gtin} at {location}: {old_quantity} → {stock_data.quantity}" ) return existing_stock else: # Create new stock entry new_stock = Stock( gtin=normalized_gtin, location=location, quantity=stock_data.quantity ) db.add(new_stock) db.commit() db.refresh(new_stock) logger.info( f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}" ) return new_stock except (InvalidQuantityException, StockValidationException): db.rollback() raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error setting stock: {str(e)}") raise ValidationException("Failed to set stock") def add_stock(self, db: Session, stock_data: StockAdd) -> Stock: """ Add quantity to existing stock for a GTIN at a specific location (adds to existing quantity). Args: db: Database session stock_data: Stock addition data Returns: Stock object with updated quantity Raises: InvalidQuantityException: If quantity is not positive StockValidationException: If GTIN or location is invalid """ try: # Validate and normalize input normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin) location = self._validate_and_normalize_location(stock_data.location) self._validate_quantity(stock_data.quantity, allow_zero=False) # Check if stock entry already exists for this GTIN and location existing_stock = self._get_stock_entry(db, normalized_gtin, location) if existing_stock: # Add to existing stock old_quantity = existing_stock.quantity existing_stock.quantity += stock_data.quantity existing_stock.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(existing_stock) logger.info( f"Added stock for GTIN {normalized_gtin} at {location}: " f"{old_quantity} + {stock_data.quantity} = {existing_stock.quantity}" ) return existing_stock else: # Create new stock entry with the quantity new_stock = Stock( gtin=normalized_gtin, location=location, quantity=stock_data.quantity ) db.add(new_stock) db.commit() db.refresh(new_stock) logger.info( f"Created new stock for GTIN {normalized_gtin} at {location}: {stock_data.quantity}" ) return new_stock except (InvalidQuantityException, StockValidationException): db.rollback() raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error adding stock: {str(e)}") raise ValidationException("Failed to add stock") def remove_stock(self, db: Session, stock_data: StockAdd) -> Stock: """ Remove quantity from existing stock for a GTIN at a specific location. Args: db: Database session stock_data: Stock removal data Returns: Stock object with updated quantity Raises: StockNotFoundException: If no stock found for GTIN/location InsufficientStockException: If not enough stock available InvalidQuantityException: If quantity is not positive NegativeStockException: If operation would result in negative stock """ try: # Validate and normalize input normalized_gtin = self._validate_and_normalize_gtin(stock_data.gtin) location = self._validate_and_normalize_location(stock_data.location) self._validate_quantity(stock_data.quantity, allow_zero=False) # Find existing stock entry existing_stock = self._get_stock_entry(db, normalized_gtin, location) if not existing_stock: raise StockNotFoundException(normalized_gtin, identifier_type="gtin") # Check if we have enough stock to remove if existing_stock.quantity < stock_data.quantity: raise InsufficientStockException( gtin=normalized_gtin, location=location, requested=stock_data.quantity, available=existing_stock.quantity ) # Remove from existing stock old_quantity = existing_stock.quantity new_quantity = existing_stock.quantity - stock_data.quantity # Validate resulting quantity if new_quantity < 0: raise NegativeStockException(normalized_gtin, location, new_quantity) existing_stock.quantity = new_quantity existing_stock.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(existing_stock) logger.info( f"Removed stock for GTIN {normalized_gtin} at {location}: " f"{old_quantity} - {stock_data.quantity} = {existing_stock.quantity}" ) return existing_stock except (StockValidationException, StockNotFoundException, InsufficientStockException, InvalidQuantityException, NegativeStockException): db.rollback() raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error removing stock: {str(e)}") raise ValidationException("Failed to remove stock") def get_stock_by_gtin(self, db: Session, gtin: str) -> StockSummaryResponse: """ Get all stock locations and total quantity for a specific GTIN. Args: db: Database session gtin: GTIN to look up Returns: StockSummaryResponse with locations and totals Raises: StockNotFoundException: If no stock found for GTIN StockValidationException: If GTIN is invalid """ try: normalized_gtin = self._validate_and_normalize_gtin(gtin) # Get all stock entries for this GTIN stock_entries = db.query(Stock).filter(Stock.gtin == normalized_gtin).all() if not stock_entries: raise StockNotFoundException(normalized_gtin, identifier_type="gtin") # Calculate total quantity and build locations list total_quantity = 0 locations = [] for entry in stock_entries: total_quantity += entry.quantity locations.append( StockLocationResponse(location=entry.location, quantity=entry.quantity) ) # Try to get product title for reference product = db.query(Product).filter(Product.gtin == normalized_gtin).first() product_title = product.title if product else None return StockSummaryResponse( gtin=normalized_gtin, total_quantity=total_quantity, locations=locations, product_title=product_title, ) except (StockNotFoundException, StockValidationException): raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error getting stock by GTIN {gtin}: {str(e)}") raise ValidationException("Failed to retrieve stock information") def get_total_stock(self, db: Session, gtin: str) -> dict: """ Get total quantity in stock for a specific GTIN. Args: db: Database session gtin: GTIN to look up Returns: Dictionary with total stock information Raises: StockNotFoundException: If no stock found for GTIN StockValidationException: If GTIN is invalid """ try: normalized_gtin = self._validate_and_normalize_gtin(gtin) # Calculate total stock total_stock = db.query(Stock).filter(Stock.gtin == normalized_gtin).all() if not total_stock: raise StockNotFoundException(normalized_gtin, identifier_type="gtin") total_quantity = sum(entry.quantity for entry in total_stock) # Get product info for context product = db.query(Product).filter(Product.gtin == normalized_gtin).first() return { "gtin": normalized_gtin, "total_quantity": total_quantity, "product_title": product.title if product else None, "locations_count": len(total_stock), } except (StockNotFoundException, StockValidationException): raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error getting total stock for GTIN {gtin}: {str(e)}") raise ValidationException("Failed to retrieve total stock") def get_all_stock( self, db: Session, skip: int = 0, limit: int = 100, location: Optional[str] = None, gtin: Optional[str] = None, ) -> List[Stock]: """ Get all stock entries with optional filtering. Args: db: Database session skip: Number of records to skip limit: Maximum records to return location: Optional location filter gtin: Optional GTIN filter Returns: List of Stock objects """ try: query = db.query(Stock) if location: query = query.filter(Stock.location.ilike(f"%{location}%")) if gtin: normalized_gtin = self._normalize_gtin(gtin) if normalized_gtin: query = query.filter(Stock.gtin == normalized_gtin) return query.offset(skip).limit(limit).all() except Exception as e: logger.error(f"Error getting all stock: {str(e)}") raise ValidationException("Failed to retrieve stock entries") def update_stock( self, db: Session, stock_id: int, stock_update: StockUpdate ) -> Stock: """ Update stock quantity for a specific stock entry. Args: db: Database session stock_id: Stock entry ID stock_update: Update data Returns: Updated Stock object Raises: StockNotFoundException: If stock entry not found InvalidQuantityException: If quantity is invalid """ try: stock_entry = self._get_stock_by_id_or_raise(db, stock_id) # Validate new quantity self._validate_quantity(stock_update.quantity, allow_zero=True) stock_entry.quantity = stock_update.quantity stock_entry.updated_at = datetime.now(timezone.utc) db.commit() db.refresh(stock_entry) logger.info( f"Updated stock entry {stock_id} to quantity {stock_update.quantity}" ) return stock_entry except (StockNotFoundException, InvalidQuantityException): db.rollback() raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error updating stock {stock_id}: {str(e)}") raise ValidationException("Failed to update stock") def delete_stock(self, db: Session, stock_id: int) -> bool: """ Delete a stock entry. Args: db: Database session stock_id: Stock entry ID Returns: True if deletion successful Raises: StockNotFoundException: If stock entry not found """ try: stock_entry = self._get_stock_by_id_or_raise(db, stock_id) gtin = stock_entry.gtin location = stock_entry.location db.delete(stock_entry) db.commit() logger.info(f"Deleted stock entry {stock_id} for GTIN {gtin} at {location}") return True except StockNotFoundException: raise # Re-raise custom exceptions except Exception as e: db.rollback() logger.error(f"Error deleting stock {stock_id}: {str(e)}") raise ValidationException("Failed to delete stock entry") def get_stock_summary_by_location(self, db: Session, location: str) -> dict: """ Get stock summary for a specific location. Args: db: Database session location: Location to summarize Returns: Dictionary with location stock summary """ try: normalized_location = self._validate_and_normalize_location(location) stock_entries = db.query(Stock).filter(Stock.location == normalized_location).all() if not stock_entries: return { "location": normalized_location, "total_items": 0, "total_quantity": 0, "unique_gtins": 0, } total_quantity = sum(entry.quantity for entry in stock_entries) unique_gtins = len(set(entry.gtin for entry in stock_entries)) return { "location": normalized_location, "total_items": len(stock_entries), "total_quantity": total_quantity, "unique_gtins": unique_gtins, } except StockValidationException: raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error getting stock summary for location {location}: {str(e)}") raise ValidationException("Failed to retrieve location stock summary") def get_low_stock_items(self, db: Session, threshold: int = 10) -> List[dict]: """ Get items with stock below threshold. Args: db: Database session threshold: Stock threshold to consider "low" Returns: List of low stock items with details """ try: if threshold < 0: raise InvalidQuantityException(threshold, "Threshold must be non-negative") low_stock_entries = db.query(Stock).filter(Stock.quantity <= threshold).all() low_stock_items = [] for entry in low_stock_entries: # Get product info if available product = db.query(Product).filter(Product.gtin == entry.gtin).first() low_stock_items.append({ "gtin": entry.gtin, "location": entry.location, "current_quantity": entry.quantity, "product_title": product.title if product else None, "product_id": product.product_id if product else None, }) return low_stock_items except InvalidQuantityException: raise # Re-raise custom exceptions except Exception as e: logger.error(f"Error getting low stock items: {str(e)}") raise ValidationException("Failed to retrieve low stock items") # Private helper methods def _validate_and_normalize_gtin(self, gtin: str) -> str: """Validate and normalize GTIN format.""" if not gtin or not gtin.strip(): raise StockValidationException("GTIN is required", field="gtin") normalized_gtin = self._normalize_gtin(gtin) if not normalized_gtin: raise StockValidationException("Invalid GTIN format", field="gtin") return normalized_gtin def _validate_and_normalize_location(self, location: str) -> str: """Validate and normalize location.""" if not location or not location.strip(): raise StockValidationException("Location is required", field="location") 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, "Quantity is required") if not isinstance(quantity, int): raise InvalidQuantityException(quantity, "Quantity must be an integer") if quantity < 0: raise InvalidQuantityException(quantity, "Quantity cannot be negative") if not allow_zero and quantity == 0: raise InvalidQuantityException(quantity, "Quantity must be positive") def _normalize_gtin(self, gtin_value) -> Optional[str]: """Normalize GTIN format using the GTINProcessor.""" try: return self.gtin_processor.normalize(gtin_value) except Exception as e: logger.error(f"Error normalizing GTIN {gtin_value}: {str(e)}") return None def _get_stock_entry(self, db: Session, gtin: str, location: str) -> Optional[Stock]: """Get stock entry by GTIN and location.""" return ( db.query(Stock) .filter(Stock.gtin == gtin, Stock.location == location) .first() ) def _get_stock_by_id_or_raise(self, db: Session, stock_id: int) -> Stock: """Get stock by ID or raise exception.""" stock_entry = db.query(Stock).filter(Stock.id == stock_id).first() if not stock_entry: raise StockNotFoundException(str(stock_id)) return stock_entry # Create service instance stock_service = StockService()