Files
bakery-ia/services/procurement/app/services/replenishment_planning_service.py
Claude ed7db4d4f2 feat: Complete backend i18n implementation with error codes and demo data
Demo Seed Scripts:
- Updated seed_demo_purchase_orders.py to use structured reasoning_data
  * Imports create_po_reasoning_low_stock and create_po_reasoning_supplier_contract
  * Generates reasoning_data with product names, stock levels, and consequences
  * Removed deprecated reasoning/consequence TEXT fields
- Updated seed_demo_batches.py to use structured reasoning_data
  * Imports create_batch_reasoning_forecast_demand and create_batch_reasoning_regular_schedule
  * Generates intelligent reasoning based on batch priority and AI assistance
  * Adds reasoning_data to all production batches

Backend Services - Error Code Implementation:
- Updated safety_stock_calculator.py with error codes
  * Replaced "Lead time or demand std dev is zero or negative" with ERROR:LEAD_TIME_INVALID
  * Replaced "Insufficient historical demand data" with ERROR:INSUFFICIENT_DATA
- Updated replenishment_planning_service.py with error codes
  * Replaced "Insufficient data for safety stock calculation" with ERROR:INSUFFICIENT_DATA
  * Frontend can now translate error codes using i18n

Demo data will now display with translatable reasoning in EN/ES/EU languages.
Backend services return error codes that frontend translates for user's language.
2025-11-07 18:40:44 +00:00

501 lines
16 KiB
Python

"""
Replenishment Planning Service
Main orchestrator for advanced procurement planning that integrates:
- Lead time planning
- Inventory projection
- Safety stock calculation
- Shelf life management
"""
from datetime import date, timedelta
from decimal import Decimal
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, asdict
import logging
import uuid
from .lead_time_planner import LeadTimePlanner, LeadTimeRequirement, LeadTimePlan
from .inventory_projector import (
InventoryProjector,
DailyDemand,
ScheduledReceipt,
IngredientProjection
)
from .safety_stock_calculator import SafetyStockCalculator, SafetyStockResult
from .shelf_life_manager import ShelfLifeManager, ShelfLifeAdjustment
logger = logging.getLogger(__name__)
@dataclass
class IngredientRequirement:
"""Complete requirement for one ingredient"""
ingredient_id: str
ingredient_name: str
required_quantity: Decimal
required_by_date: date
supplier_id: Optional[str] = None
lead_time_days: int = 3
shelf_life_days: Optional[int] = None
is_perishable: bool = False
category: str = 'dry'
unit_of_measure: str = 'kg'
current_stock: Decimal = Decimal('0')
daily_consumption_rate: float = 0.0
demand_std_dev: float = 0.0
@dataclass
class ReplenishmentPlanItem:
"""Single item in replenishment plan"""
id: str
ingredient_id: str
ingredient_name: str
# Quantities
base_quantity: Decimal
safety_stock_quantity: Decimal
shelf_life_adjusted_quantity: Decimal
final_order_quantity: Decimal
# Dates
order_date: date
delivery_date: date
required_by_date: date
# Metadata
lead_time_days: int
is_urgent: bool
urgency_reason: Optional[str]
waste_risk: str
stockout_risk: str
supplier_id: Optional[str]
# Calculation details
safety_stock_calculation: Dict
shelf_life_adjustment: Dict
inventory_projection: Optional[Dict]
@dataclass
class ReplenishmentPlan:
"""Complete replenishment plan"""
plan_id: str
tenant_id: str
planning_date: date
projection_horizon_days: int
items: List[ReplenishmentPlanItem]
# Summary statistics
total_items: int
urgent_items: int
high_risk_items: int
total_estimated_cost: Decimal
# Metadata
created_at: date
class ReplenishmentPlanningService:
"""
Orchestrates advanced replenishment planning.
Workflow:
1. Project inventory levels (InventoryProjector)
2. Identify coverage gaps and required quantities
3. Calculate safety stock (SafetyStockCalculator)
4. Adjust for shelf life (ShelfLifeManager)
5. Calculate order dates (LeadTimePlanner)
6. Generate complete replenishment plan
"""
def __init__(
self,
projection_horizon_days: int = 7,
default_service_level: float = 0.95,
default_buffer_days: int = 1
):
"""
Initialize replenishment planning service.
Args:
projection_horizon_days: Days to project ahead
default_service_level: Default target service level
default_buffer_days: Default buffer days for orders
"""
self.projection_horizon_days = projection_horizon_days
# Initialize sub-services
self.inventory_projector = InventoryProjector(projection_horizon_days)
self.safety_stock_calculator = SafetyStockCalculator(default_service_level)
self.shelf_life_manager = ShelfLifeManager()
self.lead_time_planner = LeadTimePlanner(default_buffer_days)
async def generate_replenishment_plan(
self,
tenant_id: str,
requirements: List[IngredientRequirement],
forecast_id: Optional[str] = None,
production_schedule_id: Optional[str] = None
) -> ReplenishmentPlan:
"""
Generate complete replenishment plan.
Args:
tenant_id: Tenant ID
requirements: List of ingredient requirements
forecast_id: Optional reference to forecast
production_schedule_id: Optional reference to production schedule
Returns:
Complete replenishment plan
"""
plan_id = str(uuid.uuid4())
planning_date = date.today()
logger.info(
f"Generating replenishment plan {plan_id} for {len(requirements)} ingredients"
)
plan_items = []
for req in requirements:
try:
item = await self._plan_ingredient_replenishment(req)
plan_items.append(item)
except Exception as e:
logger.error(
f"Failed to plan replenishment for {req.ingredient_name}: {e}"
)
# Continue with other ingredients
# Calculate summary statistics
total_items = len(plan_items)
urgent_items = sum(1 for item in plan_items if item.is_urgent)
high_risk_items = sum(
1 for item in plan_items
if item.stockout_risk in ['high', 'critical']
)
# Estimate total cost (placeholder - need price data)
total_estimated_cost = sum(
item.final_order_quantity
for item in plan_items
)
plan = ReplenishmentPlan(
plan_id=plan_id,
tenant_id=tenant_id,
planning_date=planning_date,
projection_horizon_days=self.projection_horizon_days,
items=plan_items,
total_items=total_items,
urgent_items=urgent_items,
high_risk_items=high_risk_items,
total_estimated_cost=total_estimated_cost,
created_at=planning_date
)
logger.info(
f"Replenishment plan generated: {total_items} items, "
f"{urgent_items} urgent, {high_risk_items} high risk"
)
return plan
async def _plan_ingredient_replenishment(
self,
req: IngredientRequirement
) -> ReplenishmentPlanItem:
"""
Plan replenishment for a single ingredient.
Args:
req: Ingredient requirement
Returns:
Replenishment plan item
"""
# Step 1: Project inventory to identify needs
projection = await self._project_ingredient_inventory(req)
# Step 2: Calculate base quantity needed
base_quantity = self._calculate_base_quantity(req, projection)
# Step 3: Calculate safety stock
safety_stock_result = self._calculate_safety_stock(req)
safety_stock_quantity = safety_stock_result.safety_stock_quantity
# Step 4: Adjust for shelf life
total_quantity = base_quantity + safety_stock_quantity
shelf_life_adjustment = self._adjust_for_shelf_life(
req,
total_quantity
)
# Step 5: Calculate order dates
lead_time_plan = self._calculate_order_dates(
req,
shelf_life_adjustment.adjusted_quantity
)
# Create plan item
item = ReplenishmentPlanItem(
id=str(uuid.uuid4()),
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
base_quantity=base_quantity,
safety_stock_quantity=safety_stock_quantity,
shelf_life_adjusted_quantity=shelf_life_adjustment.adjusted_quantity,
final_order_quantity=shelf_life_adjustment.adjusted_quantity,
order_date=lead_time_plan.order_date,
delivery_date=lead_time_plan.delivery_date,
required_by_date=req.required_by_date,
lead_time_days=req.lead_time_days,
is_urgent=lead_time_plan.is_urgent,
urgency_reason=lead_time_plan.urgency_reason,
waste_risk=shelf_life_adjustment.waste_risk,
stockout_risk=projection.stockout_risk if projection else 'unknown',
supplier_id=req.supplier_id,
safety_stock_calculation=self.safety_stock_calculator.export_to_dict(safety_stock_result),
shelf_life_adjustment=self.shelf_life_manager.export_to_dict(shelf_life_adjustment),
inventory_projection=self.inventory_projector.export_projection_to_dict(projection) if projection else None
)
return item
async def _project_ingredient_inventory(
self,
req: IngredientRequirement
) -> Optional[IngredientProjection]:
"""
Project inventory for ingredient.
Args:
req: Ingredient requirement
Returns:
Inventory projection
"""
try:
# Build daily demand forecast
daily_demand = []
if req.daily_consumption_rate > 0:
for i in range(self.projection_horizon_days):
demand_date = date.today() + timedelta(days=i)
daily_demand.append(
DailyDemand(
ingredient_id=req.ingredient_id,
date=demand_date,
quantity=Decimal(str(req.daily_consumption_rate))
)
)
# No scheduled receipts for now (could add future POs here)
scheduled_receipts = []
projection = self.inventory_projector.project_inventory(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
current_stock=req.current_stock,
unit_of_measure=req.unit_of_measure,
daily_demand=daily_demand,
scheduled_receipts=scheduled_receipts
)
return projection
except Exception as e:
logger.error(f"Failed to project inventory for {req.ingredient_name}: {e}")
return None
def _calculate_base_quantity(
self,
req: IngredientRequirement,
projection: Optional[IngredientProjection]
) -> Decimal:
"""
Calculate base quantity needed.
Args:
req: Ingredient requirement
projection: Inventory projection
Returns:
Base quantity
"""
if projection:
# Use projection to calculate need
required = self.inventory_projector.calculate_required_order_quantity(
projection,
target_coverage_days=self.projection_horizon_days
)
return max(required, req.required_quantity)
else:
# Fallback to required quantity
return req.required_quantity
def _calculate_safety_stock(
self,
req: IngredientRequirement
) -> SafetyStockResult:
"""
Calculate safety stock.
Args:
req: Ingredient requirement
Returns:
Safety stock result
"""
if req.demand_std_dev > 0:
# Use statistical method
return self.safety_stock_calculator.calculate_safety_stock(
demand_std_dev=req.demand_std_dev,
lead_time_days=req.lead_time_days
)
elif req.daily_consumption_rate > 0:
# Use percentage method
return self.safety_stock_calculator.calculate_using_fixed_percentage(
average_demand=req.daily_consumption_rate,
lead_time_days=req.lead_time_days,
percentage=0.20
)
else:
# No safety stock
return SafetyStockResult(
safety_stock_quantity=Decimal('0'),
service_level=0.0,
z_score=0.0,
demand_std_dev=0.0,
lead_time_days=req.lead_time_days,
calculation_method='none',
confidence='low',
reasoning='ERROR:INSUFFICIENT_DATA' # Error code for i18n translation
)
def _adjust_for_shelf_life(
self,
req: IngredientRequirement,
quantity: Decimal
) -> ShelfLifeAdjustment:
"""
Adjust quantity for shelf life constraints.
Args:
req: Ingredient requirement
quantity: Proposed quantity
Returns:
Shelf life adjustment
"""
if not req.is_perishable or not req.shelf_life_days:
# No shelf life constraint
return ShelfLifeAdjustment(
original_quantity=quantity,
adjusted_quantity=quantity,
adjustment_reason='Non-perishable or no shelf life data',
waste_risk='low',
recommended_order_date=date.today(),
use_by_date=date.today() + timedelta(days=365),
is_constrained=False
)
return self.shelf_life_manager.adjust_order_quantity_for_shelf_life(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
requested_quantity=quantity,
daily_consumption_rate=req.daily_consumption_rate,
shelf_life_days=req.shelf_life_days,
category=req.category,
is_perishable=req.is_perishable,
delivery_date=req.required_by_date - timedelta(days=req.lead_time_days)
)
def _calculate_order_dates(
self,
req: IngredientRequirement,
quantity: Decimal
) -> LeadTimePlan:
"""
Calculate order and delivery dates.
Args:
req: Ingredient requirement
quantity: Order quantity
Returns:
Lead time plan
"""
lead_time_req = LeadTimeRequirement(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
required_quantity=quantity,
required_by_date=req.required_by_date,
supplier_id=req.supplier_id,
lead_time_days=req.lead_time_days
)
plans = self.lead_time_planner.plan_requirements([lead_time_req])
return plans[0] if plans else LeadTimePlan(
ingredient_id=req.ingredient_id,
ingredient_name=req.ingredient_name,
order_quantity=quantity,
order_date=date.today(),
delivery_date=date.today() + timedelta(days=req.lead_time_days),
required_by_date=req.required_by_date,
lead_time_days=req.lead_time_days,
buffer_days=1,
is_urgent=False,
supplier_id=req.supplier_id
)
def export_plan_to_dict(self, plan: ReplenishmentPlan) -> Dict:
"""
Export plan to dictionary for API response.
Args:
plan: Replenishment plan
Returns:
Dictionary representation
"""
return {
'plan_id': plan.plan_id,
'tenant_id': plan.tenant_id,
'planning_date': plan.planning_date.isoformat(),
'projection_horizon_days': plan.projection_horizon_days,
'total_items': plan.total_items,
'urgent_items': plan.urgent_items,
'high_risk_items': plan.high_risk_items,
'total_estimated_cost': float(plan.total_estimated_cost),
'created_at': plan.created_at.isoformat(),
'items': [
{
'id': item.id,
'ingredient_id': item.ingredient_id,
'ingredient_name': item.ingredient_name,
'base_quantity': float(item.base_quantity),
'safety_stock_quantity': float(item.safety_stock_quantity),
'shelf_life_adjusted_quantity': float(item.shelf_life_adjusted_quantity),
'final_order_quantity': float(item.final_order_quantity),
'order_date': item.order_date.isoformat(),
'delivery_date': item.delivery_date.isoformat(),
'required_by_date': item.required_by_date.isoformat(),
'lead_time_days': item.lead_time_days,
'is_urgent': item.is_urgent,
'urgency_reason': item.urgency_reason,
'waste_risk': item.waste_risk,
'stockout_risk': item.stockout_risk,
'supplier_id': item.supplier_id,
'safety_stock_calculation': item.safety_stock_calculation,
'shelf_life_adjustment': item.shelf_life_adjustment,
'inventory_projection': item.inventory_projection
}
for item in plan.items
]
}