Files
bakery-ia/services/inventory/app/services/inventory_service.py
2025-09-18 08:06:32 +02:00

744 lines
33 KiB
Python

# services/inventory/app/services/inventory_service.py
"""
Inventory Service - Business Logic Layer
"""
from typing import List, Optional, Dict, Any, Tuple
from uuid import UUID
from datetime import datetime, timedelta
import structlog
from app.models.inventory import Ingredient, Stock, StockMovement, StockAlert, StockMovementType
from app.repositories.ingredient_repository import IngredientRepository
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
from app.schemas.inventory import (
IngredientCreate, IngredientUpdate, IngredientResponse,
StockCreate, StockUpdate, StockResponse,
StockMovementCreate, StockMovementResponse,
InventorySummary, StockLevelSummary
)
from app.core.database import get_db_transaction
from shared.database.exceptions import DatabaseError
from shared.utils.batch_generator import BatchNumberGenerator, create_fallback_batch_number
logger = structlog.get_logger()
class InventoryService:
"""Service layer for inventory operations"""
def __init__(self):
pass
# ===== INGREDIENT MANAGEMENT =====
async def create_ingredient(
self,
ingredient_data: IngredientCreate,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> IngredientResponse:
"""Create a new ingredient with business validation"""
try:
# Business validation
await self._validate_ingredient_data(ingredient_data, tenant_id)
async with get_db_transaction() as db:
repository = IngredientRepository(db)
# Check for duplicates
if ingredient_data.sku:
existing = await repository.get_by_sku(tenant_id, ingredient_data.sku)
if existing:
raise ValueError(f"Ingredient with SKU '{ingredient_data.sku}' already exists")
if ingredient_data.barcode:
existing = await repository.get_by_barcode(tenant_id, ingredient_data.barcode)
if existing:
raise ValueError(f"Ingredient with barcode '{ingredient_data.barcode}' already exists")
# Create ingredient
ingredient = await repository.create_ingredient(ingredient_data, tenant_id)
# Convert to response schema
ingredient_dict = ingredient.to_dict()
# Map category field based on product type
if ingredient.product_type and ingredient.product_type.value == 'finished_product':
ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None
else:
ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None
response = IngredientResponse(**ingredient_dict)
# Add computed fields
response.current_stock = 0.0
response.is_low_stock = True
response.needs_reorder = True
logger.info("Ingredient created successfully", ingredient_id=ingredient.id, name=ingredient.name)
return response
except Exception as e:
logger.error("Failed to create ingredient", error=str(e), tenant_id=tenant_id)
raise
async def get_ingredient(self, ingredient_id: UUID, tenant_id: UUID) -> Optional[IngredientResponse]:
"""Get ingredient by ID with stock information"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient or ingredient.tenant_id != tenant_id:
return None
# Get current stock levels
stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id)
# Convert to response schema
ingredient_dict = ingredient.to_dict()
# Map category field based on product type
if ingredient.product_type and ingredient.product_type.value == 'finished_product':
ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None
else:
ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None
response = IngredientResponse(**ingredient_dict)
response.current_stock = stock_totals['total_available']
response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold
response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point
return response
except Exception as e:
logger.error("Failed to get ingredient", error=str(e), ingredient_id=ingredient_id)
raise
async def update_ingredient(
self,
ingredient_id: UUID,
ingredient_data: IngredientUpdate,
tenant_id: UUID
) -> Optional[IngredientResponse]:
"""Update ingredient with business validation"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
# Check if ingredient exists and belongs to tenant
existing = await ingredient_repo.get_by_id(ingredient_id)
if not existing or existing.tenant_id != tenant_id:
return None
# Validate unique constraints
if ingredient_data.sku and ingredient_data.sku != existing.sku:
sku_check = await ingredient_repo.get_by_sku(tenant_id, ingredient_data.sku)
if sku_check and sku_check.id != ingredient_id:
raise ValueError(f"Ingredient with SKU '{ingredient_data.sku}' already exists")
if ingredient_data.barcode and ingredient_data.barcode != existing.barcode:
barcode_check = await ingredient_repo.get_by_barcode(tenant_id, ingredient_data.barcode)
if barcode_check and barcode_check.id != ingredient_id:
raise ValueError(f"Ingredient with barcode '{ingredient_data.barcode}' already exists")
# Update ingredient
updated_ingredient = await ingredient_repo.update(ingredient_id, ingredient_data)
if not updated_ingredient:
return None
# Get current stock levels
stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id)
# Convert to response schema
ingredient_dict = updated_ingredient.to_dict()
# Map category field based on product type
if updated_ingredient.product_type and updated_ingredient.product_type.value == 'finished_product':
ingredient_dict['category'] = updated_ingredient.product_category.value if updated_ingredient.product_category else None
else:
ingredient_dict['category'] = updated_ingredient.ingredient_category.value if updated_ingredient.ingredient_category else None
response = IngredientResponse(**ingredient_dict)
response.current_stock = stock_totals['total_available']
response.is_low_stock = stock_totals['total_available'] <= updated_ingredient.low_stock_threshold
response.needs_reorder = stock_totals['total_available'] <= updated_ingredient.reorder_point
return response
except Exception as e:
logger.error("Failed to update ingredient", error=str(e), ingredient_id=ingredient_id)
raise
async def get_ingredients(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
filters: Optional[Dict[str, Any]] = None
) -> List[IngredientResponse]:
"""Get ingredients with filtering and stock information"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
# Get ingredients
ingredients = await ingredient_repo.get_ingredients_by_tenant(
tenant_id, skip, limit, filters
)
responses = []
for ingredient in ingredients:
# Get current stock levels
stock_totals = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient.id)
# Convert to response schema
ingredient_dict = ingredient.to_dict()
# Map category field based on product type
if ingredient.product_type and ingredient.product_type.value == 'finished_product':
ingredient_dict['category'] = ingredient.product_category.value if ingredient.product_category else None
else:
ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None
response = IngredientResponse(**ingredient_dict)
response.current_stock = stock_totals['total_available']
response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold
response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id)
raise
# ===== STOCK MANAGEMENT =====
async def add_stock(
self,
stock_data: StockCreate,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> StockResponse:
"""Add new stock with automatic movement tracking"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
movement_repo = StockMovementRepository(db)
# Validate ingredient exists
ingredient = await ingredient_repo.get_by_id(UUID(stock_data.ingredient_id))
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError("Ingredient not found")
# Generate batch number if not provided
if not stock_data.batch_number:
try:
batch_generator = BatchNumberGenerator(stock_repo)
stock_data.batch_number = await batch_generator.generate_batch_number(
tenant_id=str(tenant_id),
prefix="INV"
)
logger.info("Generated batch number", batch_number=stock_data.batch_number)
except Exception as e:
# Fallback to a simple batch number if generation fails
stock_data.batch_number = create_fallback_batch_number("INV")
logger.warning("Used fallback batch number", batch_number=stock_data.batch_number, error=str(e))
# Create stock entry
stock = await stock_repo.create_stock_entry(stock_data, tenant_id)
# Create stock movement record
movement_data = StockMovementCreate(
ingredient_id=stock_data.ingredient_id,
stock_id=str(stock.id),
movement_type=StockMovementType.PURCHASE,
quantity=stock_data.current_quantity,
unit_cost=stock_data.unit_cost,
notes=f"Initial stock entry - Batch: {stock_data.batch_number or 'N/A'}"
)
await movement_repo.create_movement(movement_data, tenant_id, user_id)
# Update ingredient's last purchase price
if stock_data.unit_cost:
await ingredient_repo.update_last_purchase_price(
UUID(stock_data.ingredient_id),
float(stock_data.unit_cost)
)
# Convert to response schema
response = StockResponse(**stock.to_dict())
response.ingredient = IngredientResponse(**ingredient.to_dict())
logger.info("Stock added successfully", stock_id=stock.id, quantity=stock.current_quantity)
return response
except Exception as e:
logger.error("Failed to add stock", error=str(e), tenant_id=tenant_id)
raise
async def consume_stock(
self,
ingredient_id: UUID,
quantity: float,
tenant_id: UUID,
user_id: Optional[UUID] = None,
reference_number: Optional[str] = None,
notes: Optional[str] = None,
fifo: bool = True
) -> List[Dict[str, Any]]:
"""Consume stock using FIFO/LIFO with movement tracking"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
movement_repo = StockMovementRepository(db)
# Validate ingredient
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError("Ingredient not found")
# Reserve stock first
reservations = await stock_repo.reserve_stock(tenant_id, ingredient_id, quantity, fifo)
if not reservations:
raise ValueError("Insufficient stock available")
consumed_items = []
for reservation in reservations:
stock_id = UUID(reservation['stock_id'])
reserved_qty = reservation['reserved_quantity']
# Consume from reserved stock
consumed_stock = await stock_repo.consume_stock(stock_id, reserved_qty, from_reserved=True)
# Create movement record
movement_data = StockMovementCreate(
ingredient_id=str(ingredient_id),
stock_id=str(stock_id),
movement_type=StockMovementType.PRODUCTION_USE,
quantity=reserved_qty,
reference_number=reference_number,
notes=notes or f"Stock consumption - Batch: {reservation.get('batch_number', 'N/A')}"
)
await movement_repo.create_movement(movement_data, tenant_id, user_id)
consumed_items.append({
'stock_id': str(stock_id),
'quantity_consumed': reserved_qty,
'batch_number': reservation.get('batch_number'),
'expiration_date': reservation.get('expiration_date')
})
logger.info(
"Stock consumed successfully",
ingredient_id=ingredient_id,
total_quantity=quantity,
items_consumed=len(consumed_items)
)
return consumed_items
except Exception as e:
logger.error("Failed to consume stock", error=str(e), ingredient_id=ingredient_id)
raise
async def get_stock_by_ingredient(
self,
ingredient_id: UUID,
tenant_id: UUID,
include_unavailable: bool = False
) -> List[StockResponse]:
"""Get all stock entries for an ingredient"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
# Validate ingredient
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient or ingredient.tenant_id != tenant_id:
return []
# Get stock entries
stock_entries = await stock_repo.get_stock_by_ingredient(
tenant_id, ingredient_id, include_unavailable
)
responses = []
for stock in stock_entries:
response = StockResponse(**stock.to_dict())
response.ingredient = IngredientResponse(**ingredient.to_dict())
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id)
raise
async def get_stock_movements(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
ingredient_id: Optional[UUID] = None,
movement_type: Optional[str] = None
) -> List[StockMovementResponse]:
"""Get stock movements with filtering"""
logger.info("📈 Getting stock movements",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
skip=skip,
limit=limit)
try:
async with get_db_transaction() as db:
movement_repo = StockMovementRepository(db)
ingredient_repo = IngredientRepository(db)
# Get filtered movements
movements = await movement_repo.get_movements(
tenant_id=tenant_id,
skip=skip,
limit=limit,
ingredient_id=ingredient_id,
movement_type=movement_type
)
logger.info("📊 Found movements", count=len(movements))
responses = []
for movement in movements:
response = StockMovementResponse(**movement.to_dict())
# Include ingredient information if needed
if movement.ingredient_id:
ingredient = await ingredient_repo.get_by_id(movement.ingredient_id)
if ingredient:
response.ingredient = IngredientResponse(**ingredient.to_dict())
responses.append(response)
logger.info("✅ Returning movements", response_count=len(responses))
return responses
except Exception as e:
logger.error("❌ Failed to get stock movements", error=str(e), tenant_id=tenant_id)
raise
# ===== ALERTS AND NOTIFICATIONS =====
async def check_low_stock_alerts(self, tenant_id: UUID) -> List[Dict[str, Any]]:
"""Check for ingredients with low stock levels"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
low_stock_items = await ingredient_repo.get_low_stock_ingredients(tenant_id)
alerts = []
for item in low_stock_items:
ingredient = item['ingredient']
alerts.append({
'ingredient_id': str(ingredient.id),
'ingredient_name': ingredient.name,
'current_stock': item['current_stock'],
'threshold': item['threshold'],
'needs_reorder': item['needs_reorder'],
'alert_type': 'reorder_needed' if item['needs_reorder'] else 'low_stock',
'severity': 'high' if item['needs_reorder'] else 'medium'
})
return alerts
except Exception as e:
logger.error("Failed to check low stock alerts", error=str(e), tenant_id=tenant_id)
raise
async def check_expiration_alerts(self, tenant_id: UUID, days_ahead: int = 7) -> List[Dict[str, Any]]:
"""Check for stock items expiring soon"""
try:
async with get_db_transaction() as db:
stock_repo = StockRepository(db)
expiring_items = await stock_repo.get_expiring_stock(tenant_id, days_ahead)
alerts = []
for stock, ingredient in expiring_items:
days_to_expiry = (stock.expiration_date - datetime.now()).days if stock.expiration_date else None
alerts.append({
'stock_id': str(stock.id),
'ingredient_id': str(ingredient.id),
'ingredient_name': ingredient.name,
'batch_number': stock.batch_number,
'quantity': stock.available_quantity,
'expiration_date': stock.expiration_date,
'days_to_expiry': days_to_expiry,
'alert_type': 'expiring_soon',
'severity': 'critical' if days_to_expiry and days_to_expiry <= 1 else 'high'
})
return alerts
except Exception as e:
logger.error("Failed to check expiration alerts", error=str(e), tenant_id=tenant_id)
raise
# ===== DASHBOARD AND ANALYTICS =====
async def get_inventory_summary(self, tenant_id: UUID) -> InventorySummary:
"""Get inventory summary for dashboard"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
movement_repo = StockMovementRepository(db)
# Get basic counts
total_ingredients_result = await ingredient_repo.count({'tenant_id': tenant_id, 'is_active': True})
# Get stock summary
stock_summary = await stock_repo.get_stock_summary_by_tenant(tenant_id)
# Get low stock and expiring items
low_stock_items = await ingredient_repo.get_low_stock_ingredients(tenant_id)
expiring_items = await stock_repo.get_expiring_stock(tenant_id, 7)
expired_items = await stock_repo.get_expired_stock(tenant_id)
# Get recent activity
recent_activity = await movement_repo.get_movement_summary_by_period(tenant_id, 7)
# Build category breakdown
stock_by_category = {}
ingredients = await ingredient_repo.get_ingredients_by_tenant(tenant_id, 0, 1000)
for ingredient in ingredients:
category = ingredient.category.value if ingredient.category else 'other'
if category not in stock_by_category:
stock_by_category[category] = {
'count': 0,
'total_value': 0.0,
'low_stock_items': 0
}
stock_by_category[category]['count'] += 1
# Additional calculations would go here
return InventorySummary(
total_ingredients=total_ingredients_result,
total_stock_value=stock_summary['total_stock_value'],
low_stock_alerts=len(low_stock_items),
expiring_soon_items=len(expiring_items),
expired_items=len(expired_items),
out_of_stock_items=0, # TODO: Calculate this
stock_by_category=stock_by_category,
recent_movements=recent_activity.get('total_movements', 0),
recent_purchases=recent_activity.get('purchase', {}).get('count', 0),
recent_waste=recent_activity.get('waste', {}).get('count', 0)
)
except Exception as e:
logger.error("Failed to get inventory summary", error=str(e), tenant_id=tenant_id)
raise
async def get_stock(
self,
tenant_id: UUID,
skip: int = 0,
limit: int = 100,
ingredient_id: Optional[UUID] = None,
available_only: bool = True
) -> List[StockResponse]:
"""Get stock entries with filtering"""
try:
async with get_db_transaction() as db:
stock_repo = StockRepository(db)
ingredient_repo = IngredientRepository(db)
# Get stock entries
stock_entries = await stock_repo.get_stock_entries(
tenant_id, skip, limit, ingredient_id, available_only
)
responses = []
for stock in stock_entries:
# Get ingredient information
ingredient = await ingredient_repo.get_by_id(stock.ingredient_id)
response = StockResponse(**stock.to_dict())
if ingredient:
response.ingredient = IngredientResponse(**ingredient.to_dict())
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get stock entries", error=str(e), tenant_id=tenant_id)
raise
# ===== DELETION METHODS =====
async def hard_delete_ingredient(
self,
ingredient_id: UUID,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> Dict[str, Any]:
"""
Completely delete an ingredient and all associated data.
This includes:
- All stock entries
- All stock movements
- All stock alerts
- The ingredient record itself
Returns a summary of what was deleted.
"""
try:
deletion_summary = {
"ingredient_id": str(ingredient_id),
"deleted_stock_entries": 0,
"deleted_stock_movements": 0,
"deleted_stock_alerts": 0,
"ingredient_name": None,
"success": False
}
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
movement_repo = StockMovementRepository(db)
# 1. Verify ingredient exists and belongs to tenant
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError(f"Ingredient {ingredient_id} not found or access denied")
deletion_summary["ingredient_name"] = ingredient.name
logger.info(
"Starting hard deletion of ingredient",
ingredient_id=str(ingredient_id),
ingredient_name=ingredient.name,
tenant_id=str(tenant_id)
)
# 2. Delete all stock movements first (due to foreign key constraints)
try:
deleted_movements = await movement_repo.delete_by_ingredient(ingredient_id, tenant_id)
deletion_summary["deleted_stock_movements"] = deleted_movements
logger.info(f"Deleted {deleted_movements} stock movements")
except Exception as e:
logger.warning(f"Error deleting stock movements: {str(e)}")
# Continue with deletion even if this fails
# 3. Delete all stock entries
try:
deleted_stock = await stock_repo.delete_by_ingredient(ingredient_id, tenant_id)
deletion_summary["deleted_stock_entries"] = deleted_stock
logger.info(f"Deleted {deleted_stock} stock entries")
except Exception as e:
logger.warning(f"Error deleting stock entries: {str(e)}")
# Continue with deletion even if this fails
# 4. Delete stock alerts if they exist
try:
# Note: StockAlert deletion would go here if that table exists
# For now, we'll assume this is handled by cascading deletes or doesn't exist
deletion_summary["deleted_stock_alerts"] = 0
except Exception as e:
logger.warning(f"Error deleting stock alerts: {str(e)}")
# 5. Finally, delete the ingredient itself
deleted_ingredient = await ingredient_repo.delete_by_id(ingredient_id, tenant_id)
if not deleted_ingredient:
raise ValueError("Failed to delete ingredient record")
deletion_summary["success"] = True
logger.info(
"Successfully completed hard deletion of ingredient",
ingredient_id=str(ingredient_id),
ingredient_name=ingredient.name,
summary=deletion_summary
)
return deletion_summary
except ValueError:
# Re-raise validation errors
raise
except Exception as e:
logger.error(
"Failed to hard delete ingredient",
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id),
error=str(e)
)
raise
async def soft_delete_ingredient(
self,
ingredient_id: UUID,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> IngredientResponse:
"""
Soft delete an ingredient (mark as inactive).
This preserves all associated data for reporting and audit purposes.
"""
try:
async with get_db_transaction() as db:
ingredient_repo = IngredientRepository(db)
# Verify ingredient exists and belongs to tenant
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError(f"Ingredient {ingredient_id} not found or access denied")
# Mark as inactive
update_data = IngredientUpdate(is_active=False)
updated_ingredient = await ingredient_repo.update_ingredient(ingredient_id, update_data)
logger.info(
"Soft deleted ingredient",
ingredient_id=str(ingredient_id),
ingredient_name=ingredient.name,
tenant_id=str(tenant_id)
)
return IngredientResponse(**updated_ingredient.to_dict())
except ValueError:
raise
except Exception as e:
logger.error(
"Failed to soft delete ingredient",
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id),
error=str(e)
)
raise
# ===== PRIVATE HELPER METHODS =====
async def _validate_ingredient_data(self, ingredient_data: IngredientCreate, tenant_id: UUID):
"""Validate ingredient data for business rules"""
# Add business validation logic here
if ingredient_data.reorder_point <= ingredient_data.low_stock_threshold:
raise ValueError("Reorder point must be greater than low stock threshold")
if ingredient_data.requires_freezing and ingredient_data.requires_refrigeration:
raise ValueError("Item cannot require both freezing and refrigeration")
# Add more validations as needed
pass