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