Files
bakery-ia/services/inventory/app/services/inventory_service.py
Urtzi Alfaro 93c9475239 fix UI 1
2026-01-01 19:01:33 +01:00

1200 lines
53 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
import uuid
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
from app.utils.cache import delete_cached, make_cache_key
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)
# Auto-generate SKU if not provided
if not ingredient_data.sku:
ingredient_data.sku = await self._generate_sku(db, tenant_id, ingredient_data.name)
logger.info("Auto-generated SKU", sku=ingredient_data.sku, name=ingredient_data.name)
# 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
if ingredient.low_stock_threshold is not None else False
)
response.needs_reorder = (
stock_totals['total_available'] <= ingredient.reorder_point
if ingredient.reorder_point is not None else False
)
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
if updated_ingredient.low_stock_threshold is not None else False
)
response.needs_reorder = (
stock_totals['total_available'] <= updated_ingredient.reorder_point
if updated_ingredient.reorder_point is not None else False
)
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
if ingredient.low_stock_threshold is not None else False
)
response.needs_reorder = (
stock_totals['total_available'] <= ingredient.reorder_point
if ingredient.reorder_point is not None else False
)
responses.append(response)
return responses
except Exception as e:
logger.error("Failed to get ingredients", error=str(e), tenant_id=tenant_id)
raise
async def count_ingredients_by_tenant(self, tenant_id: UUID) -> int:
"""Count total number of active ingredients for a tenant"""
try:
async with get_db_transaction() as db:
# Use SQLAlchemy count query for efficiency
from sqlalchemy import select, func, and_
query = select(func.count(Ingredient.id)).where(
and_(
Ingredient.tenant_id == tenant_id,
Ingredient.is_active == True
)
)
result = await db.execute(query)
count = result.scalar() or 0
logger.info("Counted ingredients", tenant_id=tenant_id, count=count)
return count
except Exception as e:
logger.error("Failed to count 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)
# Get current stock level before this movement
current_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, UUID(stock_data.ingredient_id))
quantity_before = current_stock['total_available']
quantity_after = quantity_before + stock_data.current_quantity
# 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, quantity_before, quantity_after)
# Update ingredient's last purchase price and weighted average cost
if stock_data.unit_cost:
await ingredient_repo.update_last_purchase_price(
UUID(stock_data.ingredient_id),
float(stock_data.unit_cost)
)
# Calculate and update weighted average cost
await ingredient_repo.update_weighted_average_cost(
ingredient_id=UUID(stock_data.ingredient_id),
current_stock_quantity=quantity_before,
new_purchase_quantity=stock_data.current_quantity,
new_unit_cost=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 bulk_add_stock(
self,
bulk_data: 'BulkStockCreate',
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> 'BulkStockResponse':
"""Bulk add stock entries for efficient batch operations"""
import uuid as uuid_lib
from app.schemas.inventory import BulkStockCreate, BulkStockResult, BulkStockResponse
transaction_id = str(uuid_lib.uuid4())
results = []
total_created = 0
total_failed = 0
for index, stock_data in enumerate(bulk_data.stocks):
try:
stock_response = await self.add_stock(stock_data, tenant_id, user_id)
results.append(BulkStockResult(
index=index,
success=True,
stock=stock_response,
error=None
))
total_created += 1
except Exception as e:
logger.warning(
"Failed to create stock in bulk operation",
index=index,
ingredient_id=stock_data.ingredient_id,
error=str(e)
)
results.append(BulkStockResult(
index=index,
success=False,
stock=None,
error=str(e)
))
total_failed += 1
logger.info(
"Bulk stock operation completed",
transaction_id=transaction_id,
total_requested=len(bulk_data.stocks),
total_created=total_created,
total_failed=total_failed
)
return BulkStockResponse(
total_requested=len(bulk_data.stocks),
total_created=total_created,
total_failed=total_failed,
results=results,
transaction_id=transaction_id
)
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")
# Get current stock level before this consumption
current_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, ingredient_id)
running_stock_level = current_stock['total_available']
consumed_items = []
for reservation in reservations:
stock_id = UUID(reservation['stock_id'])
reserved_qty = reservation['reserved_quantity']
# Calculate before/after for this specific batch
batch_quantity_before = running_stock_level
batch_quantity_after = running_stock_level - reserved_qty
running_stock_level = batch_quantity_after # Update for next iteration
# Consume from reserved stock
consumed_stock = await stock_repo.consume_stock(stock_id, reserved_qty, from_reserved=True)
# Create movement record with progressive tracking
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, batch_quantity_before, batch_quantity_after)
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=str(tenant_id),
ingredient_id=str(ingredient_id) if ingredient_id else None,
skip=skip,
limit=limit,
movement_type=movement_type)
try:
async with get_db_transaction() as db:
movement_repo = StockMovementRepository(db)
ingredient_repo = IngredientRepository(db)
# Validate ingredient exists if filtering by ingredient
if ingredient_id:
ingredient = await ingredient_repo.get_by_id(ingredient_id)
if not ingredient:
logger.warning("Ingredient not found for movements query",
ingredient_id=str(ingredient_id),
tenant_id=str(tenant_id))
raise ValueError(f"Ingredient {ingredient_id} not found")
if ingredient.tenant_id != tenant_id:
logger.error("Ingredient does not belong to tenant",
ingredient_id=str(ingredient_id),
ingredient_tenant=str(ingredient.tenant_id),
requested_tenant=str(tenant_id))
raise ValueError(f"Ingredient {ingredient_id} does not belong to tenant {tenant_id}")
logger.info("Ingredient validated for movements query",
ingredient_name=ingredient.name,
ingredient_id=str(ingredient_id))
# 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 ValueError:
# Re-raise validation errors as-is
raise
except Exception as e:
logger.error("❌ Failed to get stock movements",
error=str(e),
error_type=type(e).__name__,
tenant_id=str(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:
# Determine category based on product type, similar to to_dict() method
category = 'other'
if ingredient.product_type and ingredient.product_type.value == 'finished_product':
# For finished products, use product_category
if ingredient.product_category:
category = ingredient.product_category.value
elif ingredient.ingredient_category and ingredient.ingredient_category.value != 'other':
category = ingredient.ingredient_category.value
else:
# For ingredients, use ingredient_category
if ingredient.ingredient_category and ingredient.ingredient_category.value != 'other':
category = ingredient.ingredient_category.value
elif ingredient.product_category:
category = ingredient.product_category.value
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=stock_summary.get('out_of_stock_count', 0),
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
async def create_stock_movement(
self,
movement_data: StockMovementCreate,
tenant_id: UUID,
user_id: Optional[UUID] = None
) -> StockMovementResponse:
"""Create a stock movement record with proper quantity tracking"""
try:
async with get_db_transaction() as db:
movement_repo = StockMovementRepository(db)
ingredient_repo = IngredientRepository(db)
stock_repo = StockRepository(db)
# Validate ingredient exists
ingredient = await ingredient_repo.get_by_id(UUID(movement_data.ingredient_id))
if not ingredient or ingredient.tenant_id != tenant_id:
raise ValueError("Ingredient not found")
# Get current stock level before this movement
current_stock = await stock_repo.get_total_stock_by_ingredient(tenant_id, UUID(movement_data.ingredient_id))
quantity_before = current_stock['total_available']
# Calculate quantity_after based on movement type
movement_quantity = movement_data.quantity or 0
if movement_data.movement_type in [StockMovementType.PURCHASE, StockMovementType.TRANSFORMATION, StockMovementType.INITIAL_STOCK]:
# These are additions to stock
quantity_after = quantity_before + movement_quantity
else:
# These are subtractions from stock (PRODUCTION_USE, WASTE, ADJUSTMENT)
quantity_after = quantity_before - movement_quantity
# Create stock movement record
movement = await movement_repo.create_movement(
movement_data,
tenant_id,
user_id,
quantity_before,
quantity_after
)
# Convert to response schema
response = StockMovementResponse(**movement.to_dict())
response.ingredient = IngredientResponse(**ingredient.to_dict())
logger.info(
"Stock movement created successfully",
movement_id=movement.id,
ingredient_id=movement.ingredient_id,
quantity=movement.quantity,
quantity_before=quantity_before,
quantity_after=quantity_after
)
return response
except Exception as e:
logger.error("Failed to create stock movement", error=str(e), tenant_id=tenant_id)
raise
async def get_stock_entry(
self,
stock_id: UUID,
tenant_id: UUID
) -> Optional[StockResponse]:
"""Get a single stock entry by ID"""
try:
async with get_db_transaction() as db:
stock_repo = StockRepository(db)
ingredient_repo = IngredientRepository(db)
# Get stock entry
stock = await stock_repo.get_by_id(stock_id)
# Check if stock exists and belongs to tenant
if not stock or stock.tenant_id != tenant_id:
return None
# Get ingredient information
ingredient = await ingredient_repo.get_by_id(stock.ingredient_id)
response = StockResponse(**stock.to_dict())
if ingredient:
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.ingredient = IngredientResponse(**ingredient_dict)
return response
except Exception as e:
logger.error("Failed to get stock entry", error=str(e), stock_id=stock_id, tenant_id=tenant_id)
raise
async def update_stock(
self,
stock_id: UUID,
stock_data: StockUpdate,
tenant_id: UUID
) -> Optional[StockResponse]:
"""Update a stock entry"""
try:
async with get_db_transaction() as db:
stock_repo = StockRepository(db)
ingredient_repo = IngredientRepository(db)
# Check if stock exists and belongs to tenant
existing_stock = await stock_repo.get_by_id(stock_id)
if not existing_stock or existing_stock.tenant_id != tenant_id:
return None
# Prepare update data
update_data = stock_data.model_dump(exclude_unset=True)
# Recalculate available_quantity if current_quantity or reserved_quantity changed
if 'current_quantity' in update_data or 'reserved_quantity' in update_data:
current_qty = update_data.get('current_quantity', existing_stock.current_quantity)
reserved_qty = update_data.get('reserved_quantity', existing_stock.reserved_quantity)
update_data['available_quantity'] = max(0, current_qty - reserved_qty)
# Recalculate total cost if unit_cost or current_quantity changed
if 'unit_cost' in update_data or 'current_quantity' in update_data:
unit_cost = update_data.get('unit_cost', existing_stock.unit_cost)
current_qty = update_data.get('current_quantity', existing_stock.current_quantity)
if unit_cost is not None and current_qty is not None:
from decimal import Decimal
update_data['total_cost'] = Decimal(str(unit_cost)) * Decimal(str(current_qty))
# Update the stock entry
updated_stock = await stock_repo.update(stock_id, update_data)
if not updated_stock:
return None
# Get ingredient information
ingredient = await ingredient_repo.get_by_id(updated_stock.ingredient_id)
response = StockResponse(**updated_stock.to_dict())
if ingredient:
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.ingredient = IngredientResponse(**ingredient_dict)
# PHASE 2: Invalidate inventory dashboard cache
cache_key = make_cache_key("inventory_dashboard", str(tenant_id))
await delete_cached(cache_key)
logger.debug("Invalidated inventory dashboard cache", cache_key=cache_key, tenant_id=str(tenant_id))
logger.info("Stock entry updated successfully", stock_id=stock_id, tenant_id=tenant_id)
return response
except Exception as e:
logger.error("Failed to update stock entry", error=str(e), stock_id=stock_id, tenant_id=tenant_id)
raise
async def delete_stock(
self,
stock_id: UUID,
tenant_id: UUID
) -> bool:
"""Delete a stock entry"""
try:
async with get_db_transaction() as db:
stock_repo = StockRepository(db)
# Check if stock exists and belongs to tenant
existing_stock = await stock_repo.get_by_id(stock_id)
if not existing_stock or existing_stock.tenant_id != tenant_id:
return False
# Delete the stock entry
success = await stock_repo.delete_by_id(stock_id)
if success:
logger.info("Stock entry deleted successfully", stock_id=stock_id, tenant_id=tenant_id)
return success
except Exception as e:
logger.error("Failed to delete stock entry", error=str(e), stock_id=stock_id, 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
# ===== BATCH OPERATIONS FOR SALES IMPORT =====
async def search_ingredients_by_name(
self,
product_name: str,
tenant_id: UUID,
db
) -> Optional[Ingredient]:
"""Search for an ingredient by name (case-insensitive exact match)"""
try:
repository = IngredientRepository(db)
ingredients = await repository.search_ingredients(
tenant_id=tenant_id,
search_term=product_name,
skip=0,
limit=10
)
product_name_lower = product_name.lower().strip()
for ingredient in ingredients:
if ingredient.name.lower().strip() == product_name_lower:
return ingredient
return None
except Exception as e:
logger.warning("Error searching ingredients by name",
product_name=product_name, error=str(e), tenant_id=tenant_id)
return None
async def create_ingredient_fast(
self,
ingredient_data: Dict[str, Any],
tenant_id: UUID,
db
) -> Ingredient:
"""Create ingredient without full validation for batch operations"""
try:
repository = IngredientRepository(db)
ingredient_create = IngredientCreate(
name=ingredient_data.get('name'),
product_type=ingredient_data.get('type', 'FINISHED_PRODUCT'),
unit_of_measure=ingredient_data.get('unit', 'UNITS'),
low_stock_threshold=ingredient_data.get('current_stock', 0),
reorder_point=max(ingredient_data.get('reorder_point', 1),
ingredient_data.get('current_stock', 0) + 1),
average_cost=ingredient_data.get('cost_per_unit', 0.0),
ingredient_category=ingredient_data.get('category') if ingredient_data.get('type') == 'INGREDIENT' else None,
product_category=ingredient_data.get('category') if ingredient_data.get('type') == 'FINISHED_PRODUCT' else None
)
ingredient = await repository.create_ingredient(ingredient_create, tenant_id)
logger.debug("Created ingredient fast", ingredient_id=ingredient.id, name=ingredient.name)
return ingredient
except Exception as e:
logger.error("Failed to create ingredient fast",
error=str(e), ingredient_data=ingredient_data, tenant_id=tenant_id)
raise
# ===== PRIVATE HELPER METHODS =====
async def _generate_sku(self, db, tenant_id: UUID, product_name: str) -> str:
"""
Generate unique SKU for inventory item
Format: SKU-{PREFIX}-{SEQUENCE}
Example: SKU-FLO-0001 for Flour, SKU-BRE-0023 for Bread
Following the same pattern as order number generation in orders service
"""
try:
from sqlalchemy import select, func
# Extract prefix from product name (first 3 chars, uppercase)
prefix = product_name[:3].upper() if product_name and len(product_name) >= 3 else "ITM"
# Count existing items with this SKU prefix for this tenant
# This ensures sequential numbering per prefix per tenant
stmt = select(func.count(Ingredient.id)).where(
Ingredient.tenant_id == tenant_id,
Ingredient.sku.like(f"SKU-{prefix}-%")
)
result = await db.execute(stmt)
count = result.scalar() or 0
# Generate sequential number
sequence = count + 1
sku = f"SKU-{prefix}-{sequence:04d}"
logger.info("Generated SKU", sku=sku, prefix=prefix, sequence=sequence, tenant_id=tenant_id)
return sku
except Exception as e:
logger.error("Error generating SKU, using fallback", error=str(e), product_name=product_name)
# Fallback to UUID-based SKU to ensure uniqueness
fallback_sku = f"SKU-{uuid.uuid4().hex[:8].upper()}"
logger.warning("Using fallback SKU", sku=fallback_sku)
return fallback_sku
async def _validate_ingredient_data(self, ingredient_data: IngredientCreate, tenant_id: UUID):
"""Validate ingredient data for business rules"""
# Only validate reorder_point if both values are provided
# During onboarding, these fields may be None, which is valid
if (ingredient_data.reorder_point is not None and
ingredient_data.low_stock_threshold is not None):
if ingredient_data.reorder_point <= ingredient_data.low_stock_threshold:
raise ValueError("Reorder point must be greater than low stock threshold")
# Storage requirements validation moved to stock level (not ingredient level)
# This is now handled in stock creation/update validation
# Add more validations as needed
pass