Files
bakery-ia/services/procurement/app/services/recipe_explosion_service.py
2025-10-30 21:08:07 +01:00

377 lines
14 KiB
Python

# ================================================================
# services/procurement/app/services/recipe_explosion_service.py
# ================================================================
"""
Recipe Explosion Service - Multi-level BOM (Bill of Materials) explosion
Converts finished product demand into raw ingredient requirements for locally-produced items
"""
import uuid
import structlog
from typing import Dict, List, Optional, Set, Tuple
from decimal import Decimal
from collections import defaultdict
from shared.clients.recipes_client import RecipesServiceClient
from shared.clients.inventory_client import InventoryServiceClient
from app.core.config import settings
logger = structlog.get_logger()
class CircularDependencyError(Exception):
"""Raised when a circular dependency is detected in recipe tree"""
pass
class RecipeExplosionService:
"""
Service for exploding finished product requirements into raw ingredient requirements.
Supports multi-level BOM explosion (recipes that reference other recipes).
"""
def __init__(
self,
recipes_client: RecipesServiceClient,
inventory_client: InventoryServiceClient
):
self.recipes_client = recipes_client
self.inventory_client = inventory_client
self.max_depth = settings.MAX_BOM_EXPLOSION_DEPTH # Default: 5 levels
async def explode_requirements(
self,
tenant_id: uuid.UUID,
requirements: List[Dict]
) -> Tuple[List[Dict], Dict]:
"""
Explode locally-produced finished products into raw ingredient requirements.
Args:
tenant_id: Tenant ID
requirements: List of procurement requirements (can mix locally-produced and purchased items)
Returns:
Tuple of (exploded_requirements, explosion_metadata)
- exploded_requirements: Final list with locally-produced items exploded to ingredients
- explosion_metadata: Details about the explosion process
"""
logger.info("Starting recipe explosion",
tenant_id=str(tenant_id),
total_requirements=len(requirements))
# Separate locally-produced from purchased items
locally_produced = []
purchased_direct = []
for req in requirements:
if req.get('is_locally_produced', False) and req.get('recipe_id'):
locally_produced.append(req)
else:
purchased_direct.append(req)
logger.info("Requirements categorized",
locally_produced_count=len(locally_produced),
purchased_direct_count=len(purchased_direct))
# If no locally-produced items, return as-is
if not locally_produced:
return requirements, {'explosion_performed': False, 'message': 'No locally-produced items'}
# Explode locally-produced items
exploded_ingredients = await self._explode_locally_produced_batch(
tenant_id=tenant_id,
locally_produced_requirements=locally_produced
)
# Combine purchased items with exploded ingredients
final_requirements = purchased_direct + exploded_ingredients
# Create metadata
metadata = {
'explosion_performed': True,
'locally_produced_items_count': len(locally_produced),
'purchased_direct_count': len(purchased_direct),
'exploded_ingredients_count': len(exploded_ingredients),
'total_final_requirements': len(final_requirements)
}
logger.info("Recipe explosion completed", **metadata)
return final_requirements, metadata
async def _explode_locally_produced_batch(
self,
tenant_id: uuid.UUID,
locally_produced_requirements: List[Dict]
) -> List[Dict]:
"""
Explode a batch of locally-produced requirements into raw ingredients.
Uses multi-level explosion (recursive) to handle recipes that reference other recipes.
"""
# Aggregated ingredient requirements
aggregated_ingredients: Dict[str, Dict] = {}
for req in locally_produced_requirements:
product_id = req['product_id']
recipe_id = req['recipe_id']
required_quantity = Decimal(str(req['required_quantity']))
logger.info("Exploding locally-produced item",
product_id=str(product_id),
recipe_id=str(recipe_id),
quantity=float(required_quantity))
try:
# Explode this recipe (recursive)
ingredients = await self._explode_recipe_recursive(
tenant_id=tenant_id,
recipe_id=recipe_id,
required_quantity=required_quantity,
current_depth=0,
visited_recipes=set(),
parent_requirement=req
)
# Aggregate ingredients
for ingredient in ingredients:
ingredient_id = ingredient['ingredient_id']
quantity = ingredient['quantity']
if ingredient_id in aggregated_ingredients:
# Add to existing
existing_qty = Decimal(str(aggregated_ingredients[ingredient_id]['quantity']))
aggregated_ingredients[ingredient_id]['quantity'] = float(existing_qty + quantity)
else:
# New ingredient
aggregated_ingredients[ingredient_id] = ingredient
except CircularDependencyError as e:
logger.error("Circular dependency detected",
product_id=str(product_id),
recipe_id=str(recipe_id),
error=str(e))
# Skip this item or handle gracefully
continue
except Exception as e:
logger.error("Error exploding recipe",
product_id=str(product_id),
recipe_id=str(recipe_id),
error=str(e))
continue
# Convert aggregated dict to list
return list(aggregated_ingredients.values())
async def _explode_recipe_recursive(
self,
tenant_id: uuid.UUID,
recipe_id: uuid.UUID,
required_quantity: Decimal,
current_depth: int,
visited_recipes: Set[str],
parent_requirement: Dict
) -> List[Dict]:
"""
Recursively explode a recipe into raw ingredients.
Args:
tenant_id: Tenant ID
recipe_id: Recipe to explode
required_quantity: How much of the finished product is needed
current_depth: Current recursion depth (to prevent infinite loops)
visited_recipes: Set of recipe IDs already visited (circular dependency detection)
parent_requirement: The parent procurement requirement
Returns:
List of ingredient requirements (raw materials only)
"""
# Check depth limit
if current_depth >= self.max_depth:
logger.warning("Max explosion depth reached",
recipe_id=str(recipe_id),
max_depth=self.max_depth)
raise RecursionError(f"Max BOM explosion depth ({self.max_depth}) exceeded")
# Check circular dependency
recipe_id_str = str(recipe_id)
if recipe_id_str in visited_recipes:
logger.error("Circular dependency detected",
recipe_id=recipe_id_str,
visited_recipes=list(visited_recipes))
raise CircularDependencyError(
f"Circular dependency detected: recipe {recipe_id_str} references itself"
)
# Add to visited set
visited_recipes.add(recipe_id_str)
logger.debug("Exploding recipe",
recipe_id=recipe_id_str,
required_quantity=float(required_quantity),
depth=current_depth)
# Fetch recipe from Recipes Service
recipe_data = await self.recipes_client.get_recipe_by_id(
tenant_id=str(tenant_id),
recipe_id=recipe_id_str
)
if not recipe_data:
logger.error("Recipe not found", recipe_id=recipe_id_str)
raise ValueError(f"Recipe {recipe_id_str} not found")
# Calculate scale factor
recipe_yield_quantity = Decimal(str(recipe_data.get('yield_quantity', 1)))
scale_factor = required_quantity / recipe_yield_quantity
logger.debug("Recipe scale calculation",
recipe_yield=float(recipe_yield_quantity),
required=float(required_quantity),
scale_factor=float(scale_factor))
# Get recipe ingredients
ingredients = recipe_data.get('ingredients', [])
if not ingredients:
logger.warning("Recipe has no ingredients", recipe_id=recipe_id_str)
return []
# Process each ingredient
exploded_ingredients = []
for recipe_ingredient in ingredients:
ingredient_id = uuid.UUID(recipe_ingredient['ingredient_id'])
ingredient_quantity = Decimal(str(recipe_ingredient['quantity']))
scaled_quantity = ingredient_quantity * scale_factor
logger.debug("Processing recipe ingredient",
ingredient_id=str(ingredient_id),
base_quantity=float(ingredient_quantity),
scaled_quantity=float(scaled_quantity))
# Check if this ingredient is ALSO locally produced (nested recipe)
ingredient_info = await self._get_ingredient_info(tenant_id, ingredient_id)
if ingredient_info and ingredient_info.get('produced_locally') and ingredient_info.get('recipe_id'):
# Recursive case: This ingredient has its own recipe
logger.info("Ingredient is locally produced, recursing",
ingredient_id=str(ingredient_id),
nested_recipe_id=ingredient_info['recipe_id'],
depth=current_depth + 1)
nested_ingredients = await self._explode_recipe_recursive(
tenant_id=tenant_id,
recipe_id=uuid.UUID(ingredient_info['recipe_id']),
required_quantity=scaled_quantity,
current_depth=current_depth + 1,
visited_recipes=visited_recipes.copy(), # Pass a copy to allow sibling branches
parent_requirement=parent_requirement
)
exploded_ingredients.extend(nested_ingredients)
else:
# Base case: This is a raw ingredient (not produced locally)
exploded_ingredients.append({
'ingredient_id': str(ingredient_id),
'product_id': str(ingredient_id),
'quantity': float(scaled_quantity),
'unit': recipe_ingredient.get('unit'),
'is_locally_produced': False,
'recipe_id': None,
'parent_requirement_id': parent_requirement.get('id'),
'bom_explosion_level': current_depth + 1,
'source_recipe_id': recipe_id_str
})
return exploded_ingredients
async def _get_ingredient_info(
self,
tenant_id: uuid.UUID,
ingredient_id: uuid.UUID
) -> Optional[Dict]:
"""
Get ingredient info from Inventory Service to check if it's locally produced.
Args:
tenant_id: Tenant ID
ingredient_id: Ingredient/Product ID
Returns:
Dict with ingredient info including produced_locally and recipe_id flags
"""
try:
ingredient = await self.inventory_client.get_ingredient_by_id(
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id)
)
if not ingredient:
return None
return {
'id': ingredient.get('id'),
'name': ingredient.get('name'),
'produced_locally': ingredient.get('produced_locally', False),
'recipe_id': ingredient.get('recipe_id')
}
except Exception as e:
logger.error("Error fetching ingredient info",
ingredient_id=str(ingredient_id),
error=str(e))
return None
async def validate_recipe_explosion(
self,
tenant_id: uuid.UUID,
recipe_id: uuid.UUID
) -> Dict:
"""
Validate if a recipe can be safely exploded (check for circular dependencies).
Args:
tenant_id: Tenant ID
recipe_id: Recipe to validate
Returns:
Dict with validation results
"""
try:
await self._explode_recipe_recursive(
tenant_id=tenant_id,
recipe_id=recipe_id,
required_quantity=Decimal("1"), # Test with 1 unit
current_depth=0,
visited_recipes=set(),
parent_requirement={}
)
return {
'valid': True,
'message': 'Recipe can be safely exploded'
}
except CircularDependencyError as e:
return {
'valid': False,
'error': 'circular_dependency',
'message': str(e)
}
except RecursionError as e:
return {
'valid': False,
'error': 'max_depth_exceeded',
'message': str(e)
}
except Exception as e:
return {
'valid': False,
'error': 'unknown',
'message': str(e)
}