Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

@@ -0,0 +1,469 @@
# 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
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
response = IngredientResponse(**ingredient.to_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
response = IngredientResponse(**ingredient.to_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
response = IngredientResponse(**updated_ingredient.to_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
response = IngredientResponse(**ingredient.to_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")
# 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
# ===== 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
# ===== 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