1200 lines
53 KiB
Python
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
|