# app/modules/inventory/services/inventory_service.py import logging from datetime import UTC, datetime from sqlalchemy import func from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.exceptions import ValidationException from app.modules.catalog.exceptions import ProductNotFoundException from app.modules.catalog.models import Product from app.modules.inventory.exceptions import ( InsufficientInventoryException, InvalidQuantityException, InventoryNotFoundException, InventoryValidationException, ) from app.modules.inventory.models.inventory import Inventory from app.modules.inventory.schemas.inventory import ( AdminInventoryItem, AdminInventoryListResponse, AdminInventoryLocationsResponse, AdminInventoryStats, AdminLowStockItem, AdminStoresWithInventoryResponse, AdminStoreWithInventory, InventoryAdjust, InventoryCreate, InventoryLocationResponse, InventoryReserve, InventoryUpdate, ProductInventorySummary, ) from app.modules.tenancy.exceptions import StoreNotFoundException from app.modules.tenancy.models import Store logger = logging.getLogger(__name__) class InventoryService: """Service for inventory operations with store isolation.""" def set_inventory( self, db: Session, store_id: int, inventory_data: InventoryCreate ) -> Inventory: """ Set exact inventory quantity for a product at a location (replaces existing). Args: db: Database session store_id: Store ID (from middleware) inventory_data: Inventory data Returns: Inventory object """ try: # Validate product belongs to store product = self._get_store_product(db, store_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, store_id=store_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 SQLAlchemyError as e: db.rollback() logger.error(f"Error setting inventory: {str(e)}") raise ValidationException("Failed to set inventory") def adjust_inventory( self, db: Session, store_id: int, inventory_data: InventoryAdjust ) -> Inventory: """ Adjust inventory by adding or removing quantity. Positive quantity = add, negative = remove. Args: db: Database session store_id: Store ID inventory_data: Adjustment data Returns: Updated Inventory object """ try: # Validate product belongs to store product = self._get_store_product(db, store_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, store_id=store_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 SQLAlchemyError as e: db.rollback() logger.error(f"Error adjusting inventory: {str(e)}") raise ValidationException("Failed to adjust inventory") def reserve_inventory( self, db: Session, store_id: int, reserve_data: InventoryReserve ) -> Inventory: """ Reserve inventory for an order (increases reserved_quantity). Args: db: Database session store_id: Store ID reserve_data: Reservation data Returns: Updated Inventory object """ try: # Validate product self._get_store_product(db, store_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 SQLAlchemyError as e: db.rollback() logger.error(f"Error reserving inventory: {str(e)}") raise ValidationException("Failed to reserve inventory") def release_reservation( self, db: Session, store_id: int, reserve_data: InventoryReserve ) -> Inventory: """ Release reserved inventory (decreases reserved_quantity). Args: db: Database session store_id: Store ID reserve_data: Reservation data Returns: Updated Inventory object """ try: # Validate product self._get_store_product(db, store_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 SQLAlchemyError as e: db.rollback() logger.error(f"Error releasing reservation: {str(e)}") raise ValidationException("Failed to release reservation") def fulfill_reservation( self, db: Session, store_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 store_id: Store ID reserve_data: Reservation data Returns: Updated Inventory object """ try: self._get_store_product(db, store_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 SQLAlchemyError 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, store_id: int, product_id: int ) -> ProductInventorySummary: """ Get inventory summary for a product across all locations. Args: db: Database session store_id: Store ID product_id: Product ID Returns: ProductInventorySummary """ try: product = self._get_store_product(db, store_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, store_id=store_id, product_sku=product.store_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, store_id=store_id, product_sku=product.store_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 SQLAlchemyError as e: logger.error(f"Error getting product inventory: {str(e)}") raise ValidationException("Failed to retrieve product inventory") def get_store_inventory( self, db: Session, store_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 store with filtering. Args: db: Database session store_id: Store 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.store_id == store_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 SQLAlchemyError as e: logger.error(f"Error getting store inventory: {str(e)}") raise ValidationException("Failed to retrieve store inventory") def update_inventory( self, db: Session, store_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.store_id != store_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 SQLAlchemyError as e: db.rollback() logger.error(f"Error updating inventory: {str(e)}") raise ValidationException("Failed to update inventory") def delete_inventory(self, db: Session, store_id: int, inventory_id: int) -> bool: """Delete inventory entry.""" try: inventory = self._get_inventory_by_id(db, inventory_id) # Verify ownership if inventory.store_id != store_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 SQLAlchemyError as e: db.rollback() logger.error(f"Error deleting inventory: {str(e)}") raise ValidationException("Failed to delete inventory") # ========================================================================= # Admin Methods (cross-store operations) # ========================================================================= def get_all_inventory_admin( self, db: Session, skip: int = 0, limit: int = 50, store_id: int | None = None, location: str | None = None, low_stock: int | None = None, search: str | None = None, ) -> AdminInventoryListResponse: """ Get inventory across all stores with filtering (admin only). Args: db: Database session skip: Pagination offset limit: Pagination limit store_id: Filter by store 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(Store) # Apply filters if store_id is not None: query = query.filter(Inventory.store_id == store_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 app.modules.marketplace.models import ( # IMPORT-002 MarketplaceProduct, MarketplaceProductTranslation, ) query = ( query.join(MarketplaceProduct) .outerjoin(MarketplaceProductTranslation) .filter( (MarketplaceProductTranslation.title.ilike(f"%{search}%")) | (Product.store_sku.ilike(f"%{search}%")) ) ) # Get total count before pagination total = query.count() # Apply pagination inventories = query.offset(skip).limit(limit).all() # Build response with store/product info items = [] for inv in inventories: product = inv.product store = inv.store 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, store_id=inv.store_id, store_name=store.name if store else None, store_code=store.store_code if store else None, product_title=title, product_sku=product.store_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, store_filter=store_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 ) # Stores with inventory stores_with_inventory = ( db.query(func.count(func.distinct(Inventory.store_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, stores_with_inventory=stores_with_inventory, unique_locations=unique_locations, ) def get_low_stock_items_admin( self, db: Session, threshold: int = 10, store_id: int | None = None, limit: int = 50, ) -> list[AdminLowStockItem]: """Get items with low stock levels (admin only).""" query = ( db.query(Inventory) .join(Product) .join(Store) .filter(Inventory.quantity <= threshold) ) if store_id is not None: query = query.filter(Inventory.store_id == store_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 store = inv.store 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, store_id=inv.store_id, store_name=store.name if store 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_stores_with_inventory_admin( self, db: Session ) -> AdminStoresWithInventoryResponse: """Get list of stores that have inventory entries (admin only).""" # SVC-005 - Admin function, intentionally cross-store # Use subquery to avoid DISTINCT on JSON columns (PostgreSQL can't compare JSON) store_ids_subquery = ( db.query(Inventory.store_id) .distinct() .subquery() ) stores = ( db.query(Store) .filter(Store.id.in_(db.query(store_ids_subquery.c.store_id))) .order_by(Store.name) .all() ) return AdminStoresWithInventoryResponse( stores=[ AdminStoreWithInventory( id=v.id, name=v.name, store_code=v.store_code ) for v in stores ] ) def get_inventory_locations_admin( self, db: Session, store_id: int | None = None ) -> AdminInventoryLocationsResponse: """Get list of unique inventory locations (admin only).""" query = db.query(func.distinct(Inventory.location)) if store_id is not None: query = query.filter(Inventory.store_id == store_id) locations = [loc[0] for loc in query.all()] return AdminInventoryLocationsResponse(locations=sorted(locations)) def get_store_inventory_admin( self, db: Session, store_id: int, skip: int = 0, limit: int = 50, location: str | None = None, low_stock: int | None = None, ) -> AdminInventoryListResponse: """Get inventory for a specific store (admin only).""" # Verify store exists store = db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(f"Store {store_id} not found") # Use the existing method inventories = self.get_store_inventory( db=db, store_id=store_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, store_id=inv.store_id, store_name=store.name, store_code=store.store_code, product_title=title, product_sku=product.store_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.store_id == store_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, store_filter=store_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 store 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 store_id return self.get_product_inventory(db, product.store_id, product_id) def verify_store_exists(self, db: Session, store_id: int) -> Store: """Verify store exists and return it.""" store = db.query(Store).filter(Store.id == store_id).first() if not store: raise StoreNotFoundException(f"Store {store_id} not found") return store def get_inventory_by_id_admin(self, db: Session, inventory_id: int) -> Inventory: """Get inventory by ID (admin only - returns inventory with store_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_store_product( self, db: Session, store_id: int, product_id: int ) -> Product: """Get product and verify it belongs to store.""" product = ( db.query(Product) .filter(Product.id == product_id, Product.store_id == store_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()