377 lines
14 KiB
Python
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)
|
|
}
|