Improve the frontend 3
This commit is contained in:
18
services/procurement/app/services/__init__.py
Normal file
18
services/procurement/app/services/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# ================================================================
|
||||
# services/procurement/app/services/__init__.py
|
||||
# ================================================================
|
||||
"""
|
||||
Services for Procurement Service
|
||||
"""
|
||||
|
||||
from .procurement_service import ProcurementService
|
||||
from .purchase_order_service import PurchaseOrderService
|
||||
from .recipe_explosion_service import RecipeExplosionService
|
||||
from .smart_procurement_calculator import SmartProcurementCalculator
|
||||
|
||||
__all__ = [
|
||||
"ProcurementService",
|
||||
"PurchaseOrderService",
|
||||
"RecipeExplosionService",
|
||||
"SmartProcurementCalculator",
|
||||
]
|
||||
429
services/procurement/app/services/inventory_projector.py
Normal file
429
services/procurement/app/services/inventory_projector.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""
|
||||
Inventory Projector
|
||||
|
||||
Projects future inventory levels day-by-day to identify coverage gaps
|
||||
and stockout risks before they occur.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
from shared.utils.time_series_utils import generate_future_dates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyDemand:
|
||||
"""Daily demand forecast for an ingredient"""
|
||||
ingredient_id: str
|
||||
date: date
|
||||
quantity: Decimal
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledReceipt:
|
||||
"""Planned receipt (PO, production, etc.)"""
|
||||
ingredient_id: str
|
||||
date: date
|
||||
quantity: Decimal
|
||||
source: str # 'purchase_order', 'production', 'transfer'
|
||||
reference_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InventoryLevel:
|
||||
"""Current inventory level"""
|
||||
ingredient_id: str
|
||||
quantity: Decimal
|
||||
unit_of_measure: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyProjection:
|
||||
"""Daily inventory projection"""
|
||||
date: date
|
||||
starting_stock: Decimal
|
||||
forecasted_consumption: Decimal
|
||||
scheduled_receipts: Decimal
|
||||
projected_ending_stock: Decimal
|
||||
is_stockout: bool
|
||||
coverage_gap: Decimal # Negative amount if stockout
|
||||
|
||||
|
||||
@dataclass
|
||||
class IngredientProjection:
|
||||
"""Complete projection for one ingredient"""
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
current_stock: Decimal
|
||||
unit_of_measure: str
|
||||
projection_horizon_days: int
|
||||
daily_projections: List[DailyProjection] = field(default_factory=list)
|
||||
total_consumption: Decimal = Decimal('0')
|
||||
total_receipts: Decimal = Decimal('0')
|
||||
stockout_days: int = 0
|
||||
stockout_risk: str = "low" # low, medium, high
|
||||
|
||||
|
||||
class InventoryProjector:
|
||||
"""
|
||||
Projects inventory levels over time to identify coverage gaps.
|
||||
|
||||
Algorithm:
|
||||
For each day in horizon:
|
||||
Starting Stock = Previous Day's Ending Stock
|
||||
Consumption = Forecasted Demand
|
||||
Receipts = Scheduled Deliveries + Production
|
||||
Ending Stock = Starting Stock - Consumption + Receipts
|
||||
|
||||
Identifies:
|
||||
- Days when stock goes negative (stockouts)
|
||||
- Coverage gaps (how much short)
|
||||
- Stockout risk level
|
||||
"""
|
||||
|
||||
def __init__(self, projection_horizon_days: int = 7):
|
||||
"""
|
||||
Initialize inventory projector.
|
||||
|
||||
Args:
|
||||
projection_horizon_days: Number of days to project
|
||||
"""
|
||||
self.projection_horizon_days = projection_horizon_days
|
||||
|
||||
def project_inventory(
|
||||
self,
|
||||
ingredient_id: str,
|
||||
ingredient_name: str,
|
||||
current_stock: Decimal,
|
||||
unit_of_measure: str,
|
||||
daily_demand: List[DailyDemand],
|
||||
scheduled_receipts: List[ScheduledReceipt],
|
||||
start_date: Optional[date] = None
|
||||
) -> IngredientProjection:
|
||||
"""
|
||||
Project inventory levels for one ingredient.
|
||||
|
||||
Args:
|
||||
ingredient_id: Ingredient ID
|
||||
ingredient_name: Ingredient name
|
||||
current_stock: Current inventory level
|
||||
unit_of_measure: Unit of measure
|
||||
daily_demand: List of daily demand forecasts
|
||||
scheduled_receipts: List of scheduled receipts
|
||||
start_date: Starting date (defaults to today)
|
||||
|
||||
Returns:
|
||||
IngredientProjection with daily projections
|
||||
"""
|
||||
if start_date is None:
|
||||
start_date = date.today()
|
||||
|
||||
# Generate projection dates
|
||||
projection_dates = generate_future_dates(start_date, self.projection_horizon_days)
|
||||
|
||||
# Build demand lookup
|
||||
demand_by_date = {d.date: d.quantity for d in daily_demand}
|
||||
|
||||
# Build receipts lookup
|
||||
receipts_by_date: Dict[date, Decimal] = {}
|
||||
for receipt in scheduled_receipts:
|
||||
if receipt.date not in receipts_by_date:
|
||||
receipts_by_date[receipt.date] = Decimal('0')
|
||||
receipts_by_date[receipt.date] += receipt.quantity
|
||||
|
||||
# Project day by day
|
||||
daily_projections = []
|
||||
running_stock = current_stock
|
||||
total_consumption = Decimal('0')
|
||||
total_receipts = Decimal('0')
|
||||
stockout_days = 0
|
||||
|
||||
for projection_date in projection_dates:
|
||||
starting_stock = running_stock
|
||||
|
||||
# Get consumption for this day
|
||||
consumption = demand_by_date.get(projection_date, Decimal('0'))
|
||||
|
||||
# Get receipts for this day
|
||||
receipts = receipts_by_date.get(projection_date, Decimal('0'))
|
||||
|
||||
# Calculate ending stock
|
||||
ending_stock = starting_stock - consumption + receipts
|
||||
|
||||
# Check for stockout
|
||||
is_stockout = ending_stock < Decimal('0')
|
||||
coverage_gap = min(Decimal('0'), ending_stock)
|
||||
|
||||
if is_stockout:
|
||||
stockout_days += 1
|
||||
|
||||
# Create daily projection
|
||||
daily_proj = DailyProjection(
|
||||
date=projection_date,
|
||||
starting_stock=starting_stock,
|
||||
forecasted_consumption=consumption,
|
||||
scheduled_receipts=receipts,
|
||||
projected_ending_stock=ending_stock,
|
||||
is_stockout=is_stockout,
|
||||
coverage_gap=coverage_gap
|
||||
)
|
||||
|
||||
daily_projections.append(daily_proj)
|
||||
|
||||
# Update running totals
|
||||
total_consumption += consumption
|
||||
total_receipts += receipts
|
||||
running_stock = ending_stock
|
||||
|
||||
# Calculate stockout risk
|
||||
stockout_risk = self._calculate_stockout_risk(
|
||||
stockout_days=stockout_days,
|
||||
total_days=len(projection_dates),
|
||||
final_stock=running_stock
|
||||
)
|
||||
|
||||
return IngredientProjection(
|
||||
ingredient_id=ingredient_id,
|
||||
ingredient_name=ingredient_name,
|
||||
current_stock=current_stock,
|
||||
unit_of_measure=unit_of_measure,
|
||||
projection_horizon_days=self.projection_horizon_days,
|
||||
daily_projections=daily_projections,
|
||||
total_consumption=total_consumption,
|
||||
total_receipts=total_receipts,
|
||||
stockout_days=stockout_days,
|
||||
stockout_risk=stockout_risk
|
||||
)
|
||||
|
||||
def project_multiple_ingredients(
|
||||
self,
|
||||
ingredients_data: List[Dict]
|
||||
) -> List[IngredientProjection]:
|
||||
"""
|
||||
Project inventory for multiple ingredients.
|
||||
|
||||
Args:
|
||||
ingredients_data: List of dicts with ingredient data
|
||||
|
||||
Returns:
|
||||
List of ingredient projections
|
||||
"""
|
||||
projections = []
|
||||
|
||||
for data in ingredients_data:
|
||||
projection = self.project_inventory(
|
||||
ingredient_id=data['ingredient_id'],
|
||||
ingredient_name=data['ingredient_name'],
|
||||
current_stock=data['current_stock'],
|
||||
unit_of_measure=data['unit_of_measure'],
|
||||
daily_demand=data.get('daily_demand', []),
|
||||
scheduled_receipts=data.get('scheduled_receipts', []),
|
||||
start_date=data.get('start_date')
|
||||
)
|
||||
|
||||
projections.append(projection)
|
||||
|
||||
return projections
|
||||
|
||||
def identify_coverage_gaps(
|
||||
self,
|
||||
projection: IngredientProjection
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Identify all coverage gaps in projection.
|
||||
|
||||
Args:
|
||||
projection: Ingredient projection
|
||||
|
||||
Returns:
|
||||
List of coverage gap details
|
||||
"""
|
||||
gaps = []
|
||||
|
||||
for daily_proj in projection.daily_projections:
|
||||
if daily_proj.is_stockout:
|
||||
gap = {
|
||||
'date': daily_proj.date,
|
||||
'shortage_quantity': abs(daily_proj.coverage_gap),
|
||||
'starting_stock': daily_proj.starting_stock,
|
||||
'consumption': daily_proj.forecasted_consumption,
|
||||
'receipts': daily_proj.scheduled_receipts
|
||||
}
|
||||
gaps.append(gap)
|
||||
|
||||
if gaps:
|
||||
logger.warning(
|
||||
f"{projection.ingredient_name}: {len(gaps)} stockout days detected"
|
||||
)
|
||||
|
||||
return gaps
|
||||
|
||||
def calculate_required_order_quantity(
|
||||
self,
|
||||
projection: IngredientProjection,
|
||||
target_coverage_days: int = 7
|
||||
) -> Decimal:
|
||||
"""
|
||||
Calculate how much to order to achieve target coverage.
|
||||
|
||||
Args:
|
||||
projection: Ingredient projection
|
||||
target_coverage_days: Target days of coverage
|
||||
|
||||
Returns:
|
||||
Required order quantity
|
||||
"""
|
||||
# Calculate average daily consumption
|
||||
if projection.daily_projections:
|
||||
avg_daily_consumption = projection.total_consumption / len(projection.daily_projections)
|
||||
else:
|
||||
return Decimal('0')
|
||||
|
||||
# Target stock level
|
||||
target_stock = avg_daily_consumption * Decimal(str(target_coverage_days))
|
||||
|
||||
# Calculate shortfall
|
||||
final_projected_stock = projection.daily_projections[-1].projected_ending_stock if projection.daily_projections else Decimal('0')
|
||||
|
||||
required_order = max(Decimal('0'), target_stock - final_projected_stock)
|
||||
|
||||
return required_order
|
||||
|
||||
def _calculate_stockout_risk(
|
||||
self,
|
||||
stockout_days: int,
|
||||
total_days: int,
|
||||
final_stock: Decimal
|
||||
) -> str:
|
||||
"""
|
||||
Calculate stockout risk level.
|
||||
|
||||
Args:
|
||||
stockout_days: Number of stockout days
|
||||
total_days: Total projection days
|
||||
final_stock: Final projected stock
|
||||
|
||||
Returns:
|
||||
Risk level: 'low', 'medium', 'high', 'critical'
|
||||
"""
|
||||
if stockout_days == 0 and final_stock > Decimal('0'):
|
||||
return "low"
|
||||
|
||||
stockout_ratio = stockout_days / total_days if total_days > 0 else 0
|
||||
|
||||
if stockout_ratio >= 0.5 or final_stock < Decimal('-100'):
|
||||
return "critical"
|
||||
elif stockout_ratio >= 0.3 or final_stock < Decimal('-50'):
|
||||
return "high"
|
||||
elif stockout_ratio > 0 or final_stock < Decimal('0'):
|
||||
return "medium"
|
||||
else:
|
||||
return "low"
|
||||
|
||||
def get_high_risk_ingredients(
|
||||
self,
|
||||
projections: List[IngredientProjection]
|
||||
) -> List[IngredientProjection]:
|
||||
"""
|
||||
Filter to high/critical risk ingredients.
|
||||
|
||||
Args:
|
||||
projections: List of ingredient projections
|
||||
|
||||
Returns:
|
||||
List of high-risk projections
|
||||
"""
|
||||
high_risk = [
|
||||
p for p in projections
|
||||
if p.stockout_risk in ['high', 'critical']
|
||||
]
|
||||
|
||||
if high_risk:
|
||||
logger.warning(f"Found {len(high_risk)} high-risk ingredients")
|
||||
for proj in high_risk:
|
||||
logger.warning(
|
||||
f" - {proj.ingredient_name}: {proj.stockout_days} stockout days, "
|
||||
f"risk={proj.stockout_risk}"
|
||||
)
|
||||
|
||||
return high_risk
|
||||
|
||||
def get_summary_statistics(
|
||||
self,
|
||||
projections: List[IngredientProjection]
|
||||
) -> Dict:
|
||||
"""
|
||||
Get summary statistics across all projections.
|
||||
|
||||
Args:
|
||||
projections: List of ingredient projections
|
||||
|
||||
Returns:
|
||||
Summary statistics
|
||||
"""
|
||||
total_ingredients = len(projections)
|
||||
stockout_ingredients = sum(1 for p in projections if p.stockout_days > 0)
|
||||
|
||||
risk_breakdown = {
|
||||
'low': sum(1 for p in projections if p.stockout_risk == 'low'),
|
||||
'medium': sum(1 for p in projections if p.stockout_risk == 'medium'),
|
||||
'high': sum(1 for p in projections if p.stockout_risk == 'high'),
|
||||
'critical': sum(1 for p in projections if p.stockout_risk == 'critical')
|
||||
}
|
||||
|
||||
total_stockout_days = sum(p.stockout_days for p in projections)
|
||||
total_consumption = sum(p.total_consumption for p in projections)
|
||||
total_receipts = sum(p.total_receipts for p in projections)
|
||||
|
||||
return {
|
||||
'total_ingredients': total_ingredients,
|
||||
'stockout_ingredients': stockout_ingredients,
|
||||
'stockout_percentage': (stockout_ingredients / total_ingredients * 100) if total_ingredients > 0 else 0,
|
||||
'risk_breakdown': risk_breakdown,
|
||||
'total_stockout_days': total_stockout_days,
|
||||
'total_consumption': float(total_consumption),
|
||||
'total_receipts': float(total_receipts),
|
||||
'projection_horizon_days': self.projection_horizon_days
|
||||
}
|
||||
|
||||
def export_projection_to_dict(
|
||||
self,
|
||||
projection: IngredientProjection
|
||||
) -> Dict:
|
||||
"""
|
||||
Export projection to dictionary for API response.
|
||||
|
||||
Args:
|
||||
projection: Ingredient projection
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
'ingredient_id': projection.ingredient_id,
|
||||
'ingredient_name': projection.ingredient_name,
|
||||
'current_stock': float(projection.current_stock),
|
||||
'unit_of_measure': projection.unit_of_measure,
|
||||
'projection_horizon_days': projection.projection_horizon_days,
|
||||
'total_consumption': float(projection.total_consumption),
|
||||
'total_receipts': float(projection.total_receipts),
|
||||
'stockout_days': projection.stockout_days,
|
||||
'stockout_risk': projection.stockout_risk,
|
||||
'daily_projections': [
|
||||
{
|
||||
'date': dp.date.isoformat(),
|
||||
'starting_stock': float(dp.starting_stock),
|
||||
'forecasted_consumption': float(dp.forecasted_consumption),
|
||||
'scheduled_receipts': float(dp.scheduled_receipts),
|
||||
'projected_ending_stock': float(dp.projected_ending_stock),
|
||||
'is_stockout': dp.is_stockout,
|
||||
'coverage_gap': float(dp.coverage_gap)
|
||||
}
|
||||
for dp in projection.daily_projections
|
||||
]
|
||||
}
|
||||
366
services/procurement/app/services/lead_time_planner.py
Normal file
366
services/procurement/app/services/lead_time_planner.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Lead Time Planner
|
||||
|
||||
Calculates order dates based on supplier lead times to ensure timely delivery.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadTimeRequirement:
|
||||
"""Requirement with lead time information"""
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
required_quantity: Decimal
|
||||
required_by_date: date
|
||||
supplier_id: Optional[str] = None
|
||||
lead_time_days: int = 0
|
||||
buffer_days: int = 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class LeadTimePlan:
|
||||
"""Planned order with dates"""
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
order_quantity: Decimal
|
||||
order_date: date
|
||||
delivery_date: date
|
||||
required_by_date: date
|
||||
lead_time_days: int
|
||||
buffer_days: int
|
||||
is_urgent: bool
|
||||
urgency_reason: Optional[str] = None
|
||||
supplier_id: Optional[str] = None
|
||||
|
||||
|
||||
class LeadTimePlanner:
|
||||
"""
|
||||
Plans order dates based on supplier lead times.
|
||||
|
||||
Ensures that:
|
||||
1. Orders are placed early enough for on-time delivery
|
||||
2. Buffer days are added for risk mitigation
|
||||
3. Urgent orders are identified
|
||||
4. Weekend/holiday adjustments can be applied
|
||||
"""
|
||||
|
||||
def __init__(self, default_buffer_days: int = 1):
|
||||
"""
|
||||
Initialize lead time planner.
|
||||
|
||||
Args:
|
||||
default_buffer_days: Default buffer days to add
|
||||
"""
|
||||
self.default_buffer_days = default_buffer_days
|
||||
|
||||
def calculate_order_date(
|
||||
self,
|
||||
required_by_date: date,
|
||||
lead_time_days: int,
|
||||
buffer_days: Optional[int] = None
|
||||
) -> date:
|
||||
"""
|
||||
Calculate when order should be placed.
|
||||
|
||||
Order Date = Required Date - Lead Time - Buffer
|
||||
|
||||
Args:
|
||||
required_by_date: Date when item is needed
|
||||
lead_time_days: Supplier lead time in days
|
||||
buffer_days: Additional buffer days (uses default if None)
|
||||
|
||||
Returns:
|
||||
Order date
|
||||
"""
|
||||
buffer = buffer_days if buffer_days is not None else self.default_buffer_days
|
||||
total_days = lead_time_days + buffer
|
||||
|
||||
order_date = required_by_date - timedelta(days=total_days)
|
||||
|
||||
return order_date
|
||||
|
||||
def calculate_delivery_date(
|
||||
self,
|
||||
order_date: date,
|
||||
lead_time_days: int
|
||||
) -> date:
|
||||
"""
|
||||
Calculate expected delivery date.
|
||||
|
||||
Delivery Date = Order Date + Lead Time
|
||||
|
||||
Args:
|
||||
order_date: Date when order is placed
|
||||
lead_time_days: Supplier lead time in days
|
||||
|
||||
Returns:
|
||||
Expected delivery date
|
||||
"""
|
||||
return order_date + timedelta(days=lead_time_days)
|
||||
|
||||
def is_urgent(
|
||||
self,
|
||||
order_date: date,
|
||||
today: date,
|
||||
urgency_threshold_days: int = 2
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Determine if order is urgent.
|
||||
|
||||
Args:
|
||||
order_date: Calculated order date
|
||||
today: Current date
|
||||
urgency_threshold_days: Days threshold for urgency
|
||||
|
||||
Returns:
|
||||
Tuple of (is_urgent, reason)
|
||||
"""
|
||||
days_until_order = (order_date - today).days
|
||||
|
||||
if days_until_order < 0:
|
||||
return True, f"Order should have been placed {abs(days_until_order)} days ago"
|
||||
elif days_until_order <= urgency_threshold_days:
|
||||
return True, f"Order must be placed within {days_until_order} days"
|
||||
else:
|
||||
return False, None
|
||||
|
||||
def plan_requirements(
|
||||
self,
|
||||
requirements: List[LeadTimeRequirement],
|
||||
today: Optional[date] = None
|
||||
) -> List[LeadTimePlan]:
|
||||
"""
|
||||
Plan order dates for multiple requirements.
|
||||
|
||||
Args:
|
||||
requirements: List of requirements with lead time info
|
||||
today: Current date (defaults to today)
|
||||
|
||||
Returns:
|
||||
List of lead time plans
|
||||
"""
|
||||
if today is None:
|
||||
today = date.today()
|
||||
|
||||
plans = []
|
||||
|
||||
for req in requirements:
|
||||
# Calculate order date
|
||||
order_date = self.calculate_order_date(
|
||||
required_by_date=req.required_by_date,
|
||||
lead_time_days=req.lead_time_days,
|
||||
buffer_days=req.buffer_days if hasattr(req, 'buffer_days') else None
|
||||
)
|
||||
|
||||
# Calculate delivery date
|
||||
delivery_date = self.calculate_delivery_date(
|
||||
order_date=order_date,
|
||||
lead_time_days=req.lead_time_days
|
||||
)
|
||||
|
||||
# Check urgency
|
||||
is_urgent, urgency_reason = self.is_urgent(
|
||||
order_date=order_date,
|
||||
today=today
|
||||
)
|
||||
|
||||
# Create plan
|
||||
plan = LeadTimePlan(
|
||||
ingredient_id=req.ingredient_id,
|
||||
ingredient_name=req.ingredient_name,
|
||||
order_quantity=req.required_quantity,
|
||||
order_date=max(order_date, today), # Can't order in the past
|
||||
delivery_date=delivery_date,
|
||||
required_by_date=req.required_by_date,
|
||||
lead_time_days=req.lead_time_days,
|
||||
buffer_days=self.default_buffer_days,
|
||||
is_urgent=is_urgent,
|
||||
urgency_reason=urgency_reason,
|
||||
supplier_id=req.supplier_id
|
||||
)
|
||||
|
||||
plans.append(plan)
|
||||
|
||||
if is_urgent:
|
||||
logger.warning(
|
||||
f"URGENT: {req.ingredient_name} - {urgency_reason}"
|
||||
)
|
||||
|
||||
# Sort by order date (urgent first)
|
||||
plans.sort(key=lambda p: (not p.is_urgent, p.order_date))
|
||||
|
||||
return plans
|
||||
|
||||
def adjust_for_working_days(
|
||||
self,
|
||||
target_date: date,
|
||||
non_working_days: List[int] = None
|
||||
) -> date:
|
||||
"""
|
||||
Adjust date to skip non-working days (e.g., weekends).
|
||||
|
||||
Args:
|
||||
target_date: Original date
|
||||
non_working_days: List of weekday numbers (0=Monday, 6=Sunday)
|
||||
|
||||
Returns:
|
||||
Adjusted date
|
||||
"""
|
||||
if non_working_days is None:
|
||||
non_working_days = [5, 6] # Saturday, Sunday
|
||||
|
||||
adjusted = target_date
|
||||
|
||||
# Move backwards to previous working day
|
||||
while adjusted.weekday() in non_working_days:
|
||||
adjusted -= timedelta(days=1)
|
||||
|
||||
return adjusted
|
||||
|
||||
def consolidate_orders_by_date(
|
||||
self,
|
||||
plans: List[LeadTimePlan],
|
||||
consolidation_window_days: int = 3
|
||||
) -> Dict[date, List[LeadTimePlan]]:
|
||||
"""
|
||||
Group orders that can be placed together.
|
||||
|
||||
Args:
|
||||
plans: List of lead time plans
|
||||
consolidation_window_days: Days within which to consolidate
|
||||
|
||||
Returns:
|
||||
Dictionary mapping order date to list of plans
|
||||
"""
|
||||
if not plans:
|
||||
return {}
|
||||
|
||||
# Sort plans by order date
|
||||
sorted_plans = sorted(plans, key=lambda p: p.order_date)
|
||||
|
||||
consolidated: Dict[date, List[LeadTimePlan]] = {}
|
||||
current_date = None
|
||||
current_batch = []
|
||||
|
||||
for plan in sorted_plans:
|
||||
if current_date is None:
|
||||
current_date = plan.order_date
|
||||
current_batch = [plan]
|
||||
else:
|
||||
days_diff = (plan.order_date - current_date).days
|
||||
|
||||
if days_diff <= consolidation_window_days:
|
||||
# Within consolidation window
|
||||
current_batch.append(plan)
|
||||
else:
|
||||
# Save current batch
|
||||
consolidated[current_date] = current_batch
|
||||
|
||||
# Start new batch
|
||||
current_date = plan.order_date
|
||||
current_batch = [plan]
|
||||
|
||||
# Save last batch
|
||||
if current_batch:
|
||||
consolidated[current_date] = current_batch
|
||||
|
||||
logger.info(
|
||||
f"Consolidated {len(plans)} orders into {len(consolidated)} order dates"
|
||||
)
|
||||
|
||||
return consolidated
|
||||
|
||||
def calculate_coverage_window(
|
||||
self,
|
||||
order_date: date,
|
||||
delivery_date: date,
|
||||
required_by_date: date
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Calculate time windows for an order.
|
||||
|
||||
Args:
|
||||
order_date: When order is placed
|
||||
delivery_date: When order arrives
|
||||
required_by_date: When item is needed
|
||||
|
||||
Returns:
|
||||
Dictionary with time windows
|
||||
"""
|
||||
return {
|
||||
"order_to_delivery_days": (delivery_date - order_date).days,
|
||||
"delivery_to_required_days": (required_by_date - delivery_date).days,
|
||||
"total_lead_time_days": (delivery_date - order_date).days,
|
||||
"buffer_time_days": (required_by_date - delivery_date).days
|
||||
}
|
||||
|
||||
def validate_plan(
|
||||
self,
|
||||
plan: LeadTimePlan,
|
||||
today: Optional[date] = None
|
||||
) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate a lead time plan for feasibility.
|
||||
|
||||
Args:
|
||||
plan: Lead time plan to validate
|
||||
today: Current date
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list of issues)
|
||||
"""
|
||||
if today is None:
|
||||
today = date.today()
|
||||
|
||||
issues = []
|
||||
|
||||
# Check if order date is in the past
|
||||
if plan.order_date < today:
|
||||
issues.append(f"Order date {plan.order_date} is in the past")
|
||||
|
||||
# Check if delivery date is before required date
|
||||
if plan.delivery_date > plan.required_by_date:
|
||||
days_late = (plan.delivery_date - plan.required_by_date).days
|
||||
issues.append(
|
||||
f"Delivery will be {days_late} days late (arrives {plan.delivery_date}, needed {plan.required_by_date})"
|
||||
)
|
||||
|
||||
# Check if lead time is reasonable
|
||||
if plan.lead_time_days > 90:
|
||||
issues.append(f"Lead time of {plan.lead_time_days} days seems unusually long")
|
||||
|
||||
# Check if order quantity is valid
|
||||
if plan.order_quantity <= 0:
|
||||
issues.append(f"Order quantity {plan.order_quantity} is invalid")
|
||||
|
||||
is_valid = len(issues) == 0
|
||||
|
||||
return is_valid, issues
|
||||
|
||||
def get_urgent_orders(
|
||||
self,
|
||||
plans: List[LeadTimePlan]
|
||||
) -> List[LeadTimePlan]:
|
||||
"""
|
||||
Filter to only urgent orders.
|
||||
|
||||
Args:
|
||||
plans: List of lead time plans
|
||||
|
||||
Returns:
|
||||
List of urgent plans
|
||||
"""
|
||||
urgent = [p for p in plans if p.is_urgent]
|
||||
|
||||
if urgent:
|
||||
logger.warning(f"Found {len(urgent)} urgent orders requiring immediate attention")
|
||||
|
||||
return urgent
|
||||
458
services/procurement/app/services/moq_aggregator.py
Normal file
458
services/procurement/app/services/moq_aggregator.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""
|
||||
MOQ Aggregator
|
||||
|
||||
Aggregates multiple procurement requirements to meet Minimum Order Quantities (MOQ)
|
||||
and optimize order sizes.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from shared.utils.optimization import (
|
||||
round_to_moq,
|
||||
round_to_package_size,
|
||||
aggregate_requirements_for_moq
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcurementRequirement:
|
||||
"""Single procurement requirement"""
|
||||
id: str
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
quantity: Decimal
|
||||
required_date: date
|
||||
supplier_id: str
|
||||
unit_of_measure: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupplierConstraints:
|
||||
"""Supplier ordering constraints"""
|
||||
supplier_id: str
|
||||
supplier_name: str
|
||||
min_order_quantity: Optional[Decimal] = None
|
||||
min_order_value: Optional[Decimal] = None
|
||||
package_size: Optional[Decimal] = None
|
||||
max_order_quantity: Optional[Decimal] = None
|
||||
economic_order_multiple: Optional[Decimal] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AggregatedOrder:
|
||||
"""Aggregated order for a supplier"""
|
||||
id: str
|
||||
supplier_id: str
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
aggregated_quantity: Decimal
|
||||
original_quantity: Decimal
|
||||
order_date: date
|
||||
unit_of_measure: str
|
||||
requirements: List[ProcurementRequirement]
|
||||
adjustment_reason: str
|
||||
moq_applied: bool
|
||||
package_rounding_applied: bool
|
||||
|
||||
|
||||
class MOQAggregator:
|
||||
"""
|
||||
Aggregates procurement requirements to meet MOQ constraints.
|
||||
|
||||
Strategies:
|
||||
1. Combine multiple requirements for same ingredient
|
||||
2. Round up to meet MOQ
|
||||
3. Round to package sizes
|
||||
4. Consolidate orders within time window
|
||||
5. Optimize order timing
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
consolidation_window_days: int = 7,
|
||||
allow_early_ordering: bool = True
|
||||
):
|
||||
"""
|
||||
Initialize MOQ aggregator.
|
||||
|
||||
Args:
|
||||
consolidation_window_days: Days within which to consolidate orders
|
||||
allow_early_ordering: Whether to allow ordering early to meet MOQ
|
||||
"""
|
||||
self.consolidation_window_days = consolidation_window_days
|
||||
self.allow_early_ordering = allow_early_ordering
|
||||
|
||||
def aggregate_requirements(
|
||||
self,
|
||||
requirements: List[ProcurementRequirement],
|
||||
supplier_constraints: Dict[str, SupplierConstraints]
|
||||
) -> List[AggregatedOrder]:
|
||||
"""
|
||||
Aggregate requirements to meet MOQ constraints.
|
||||
|
||||
Args:
|
||||
requirements: List of procurement requirements
|
||||
supplier_constraints: Dictionary of supplier constraints by supplier_id
|
||||
|
||||
Returns:
|
||||
List of aggregated orders
|
||||
"""
|
||||
if not requirements:
|
||||
return []
|
||||
|
||||
logger.info(f"Aggregating {len(requirements)} procurement requirements")
|
||||
|
||||
# Group requirements by supplier and ingredient
|
||||
grouped = self._group_requirements(requirements)
|
||||
|
||||
aggregated_orders = []
|
||||
|
||||
for (supplier_id, ingredient_id), reqs in grouped.items():
|
||||
constraints = supplier_constraints.get(supplier_id)
|
||||
|
||||
if not constraints:
|
||||
logger.warning(
|
||||
f"No constraints found for supplier {supplier_id}, "
|
||||
f"processing without MOQ"
|
||||
)
|
||||
constraints = SupplierConstraints(
|
||||
supplier_id=supplier_id,
|
||||
supplier_name=f"Supplier {supplier_id}"
|
||||
)
|
||||
|
||||
# Aggregate this group
|
||||
orders = self._aggregate_ingredient_requirements(
|
||||
reqs,
|
||||
constraints
|
||||
)
|
||||
|
||||
aggregated_orders.extend(orders)
|
||||
|
||||
logger.info(
|
||||
f"Created {len(aggregated_orders)} aggregated orders "
|
||||
f"from {len(requirements)} requirements"
|
||||
)
|
||||
|
||||
return aggregated_orders
|
||||
|
||||
def _group_requirements(
|
||||
self,
|
||||
requirements: List[ProcurementRequirement]
|
||||
) -> Dict[Tuple[str, str], List[ProcurementRequirement]]:
|
||||
"""
|
||||
Group requirements by supplier and ingredient.
|
||||
|
||||
Args:
|
||||
requirements: List of requirements
|
||||
|
||||
Returns:
|
||||
Dictionary mapping (supplier_id, ingredient_id) to list of requirements
|
||||
"""
|
||||
grouped: Dict[Tuple[str, str], List[ProcurementRequirement]] = {}
|
||||
|
||||
for req in requirements:
|
||||
key = (req.supplier_id, req.ingredient_id)
|
||||
if key not in grouped:
|
||||
grouped[key] = []
|
||||
grouped[key].append(req)
|
||||
|
||||
return grouped
|
||||
|
||||
def _aggregate_ingredient_requirements(
|
||||
self,
|
||||
requirements: List[ProcurementRequirement],
|
||||
constraints: SupplierConstraints
|
||||
) -> List[AggregatedOrder]:
|
||||
"""
|
||||
Aggregate requirements for one ingredient from one supplier.
|
||||
|
||||
Args:
|
||||
requirements: List of requirements for same ingredient/supplier
|
||||
constraints: Supplier constraints
|
||||
|
||||
Returns:
|
||||
List of aggregated orders
|
||||
"""
|
||||
if not requirements:
|
||||
return []
|
||||
|
||||
# Sort by required date
|
||||
sorted_reqs = sorted(requirements, key=lambda r: r.required_date)
|
||||
|
||||
# Try to consolidate within time window
|
||||
batches = self._consolidate_by_time_window(sorted_reqs)
|
||||
|
||||
orders = []
|
||||
|
||||
for batch in batches:
|
||||
order = self._create_aggregated_order(batch, constraints)
|
||||
orders.append(order)
|
||||
|
||||
return orders
|
||||
|
||||
def _consolidate_by_time_window(
|
||||
self,
|
||||
requirements: List[ProcurementRequirement]
|
||||
) -> List[List[ProcurementRequirement]]:
|
||||
"""
|
||||
Consolidate requirements within time window.
|
||||
|
||||
Args:
|
||||
requirements: Sorted list of requirements
|
||||
|
||||
Returns:
|
||||
List of requirement batches
|
||||
"""
|
||||
if not requirements:
|
||||
return []
|
||||
|
||||
batches = []
|
||||
current_batch = [requirements[0]]
|
||||
batch_start_date = requirements[0].required_date
|
||||
|
||||
for req in requirements[1:]:
|
||||
days_diff = (req.required_date - batch_start_date).days
|
||||
|
||||
if days_diff <= self.consolidation_window_days:
|
||||
# Within window, add to current batch
|
||||
current_batch.append(req)
|
||||
else:
|
||||
# Outside window, start new batch
|
||||
batches.append(current_batch)
|
||||
current_batch = [req]
|
||||
batch_start_date = req.required_date
|
||||
|
||||
# Add final batch
|
||||
if current_batch:
|
||||
batches.append(current_batch)
|
||||
|
||||
return batches
|
||||
|
||||
def _create_aggregated_order(
|
||||
self,
|
||||
requirements: List[ProcurementRequirement],
|
||||
constraints: SupplierConstraints
|
||||
) -> AggregatedOrder:
|
||||
"""
|
||||
Create aggregated order from requirements.
|
||||
|
||||
Args:
|
||||
requirements: List of requirements to aggregate
|
||||
constraints: Supplier constraints
|
||||
|
||||
Returns:
|
||||
Aggregated order
|
||||
"""
|
||||
# Sum quantities
|
||||
total_quantity = sum(req.quantity for req in requirements)
|
||||
original_quantity = total_quantity
|
||||
|
||||
# Get earliest required date
|
||||
order_date = min(req.required_date for req in requirements)
|
||||
|
||||
# Get ingredient info from first requirement
|
||||
first_req = requirements[0]
|
||||
ingredient_id = first_req.ingredient_id
|
||||
ingredient_name = first_req.ingredient_name
|
||||
unit_of_measure = first_req.unit_of_measure
|
||||
|
||||
# Apply constraints
|
||||
adjustment_reason = []
|
||||
moq_applied = False
|
||||
package_rounding_applied = False
|
||||
|
||||
# 1. Check MOQ
|
||||
if constraints.min_order_quantity:
|
||||
if total_quantity < constraints.min_order_quantity:
|
||||
total_quantity = constraints.min_order_quantity
|
||||
moq_applied = True
|
||||
adjustment_reason.append(
|
||||
f"Rounded up to MOQ: {constraints.min_order_quantity} {unit_of_measure}"
|
||||
)
|
||||
|
||||
# 2. Check package size
|
||||
if constraints.package_size:
|
||||
rounded_qty = round_to_package_size(
|
||||
total_quantity,
|
||||
constraints.package_size,
|
||||
allow_partial=False
|
||||
)
|
||||
if rounded_qty != total_quantity:
|
||||
total_quantity = rounded_qty
|
||||
package_rounding_applied = True
|
||||
adjustment_reason.append(
|
||||
f"Rounded to package size: {constraints.package_size} {unit_of_measure}"
|
||||
)
|
||||
|
||||
# 3. Check max order quantity
|
||||
if constraints.max_order_quantity:
|
||||
if total_quantity > constraints.max_order_quantity:
|
||||
logger.warning(
|
||||
f"{ingredient_name}: Order quantity {total_quantity} exceeds "
|
||||
f"max {constraints.max_order_quantity}, capping"
|
||||
)
|
||||
total_quantity = constraints.max_order_quantity
|
||||
adjustment_reason.append(
|
||||
f"Capped at maximum: {constraints.max_order_quantity} {unit_of_measure}"
|
||||
)
|
||||
|
||||
# 4. Apply economic order multiple
|
||||
if constraints.economic_order_multiple:
|
||||
multiple = constraints.economic_order_multiple
|
||||
rounded = round_to_moq(total_quantity, multiple, round_up=True)
|
||||
if rounded != total_quantity:
|
||||
total_quantity = rounded
|
||||
adjustment_reason.append(
|
||||
f"Rounded to economic multiple: {multiple} {unit_of_measure}"
|
||||
)
|
||||
|
||||
# Create aggregated order
|
||||
order = AggregatedOrder(
|
||||
id=f"agg_{requirements[0].id}",
|
||||
supplier_id=constraints.supplier_id,
|
||||
ingredient_id=ingredient_id,
|
||||
ingredient_name=ingredient_name,
|
||||
aggregated_quantity=total_quantity,
|
||||
original_quantity=original_quantity,
|
||||
order_date=order_date,
|
||||
unit_of_measure=unit_of_measure,
|
||||
requirements=requirements,
|
||||
adjustment_reason=" | ".join(adjustment_reason) if adjustment_reason else "No adjustments",
|
||||
moq_applied=moq_applied,
|
||||
package_rounding_applied=package_rounding_applied
|
||||
)
|
||||
|
||||
if total_quantity != original_quantity:
|
||||
logger.info(
|
||||
f"{ingredient_name}: Aggregated {len(requirements)} requirements "
|
||||
f"({original_quantity} → {total_quantity} {unit_of_measure})"
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
def calculate_order_efficiency(
|
||||
self,
|
||||
orders: List[AggregatedOrder]
|
||||
) -> Dict:
|
||||
"""
|
||||
Calculate efficiency metrics for aggregated orders.
|
||||
|
||||
Args:
|
||||
orders: List of aggregated orders
|
||||
|
||||
Returns:
|
||||
Efficiency metrics
|
||||
"""
|
||||
total_orders = len(orders)
|
||||
total_requirements = sum(len(order.requirements) for order in orders)
|
||||
|
||||
orders_with_moq = sum(1 for order in orders if order.moq_applied)
|
||||
orders_with_rounding = sum(1 for order in orders if order.package_rounding_applied)
|
||||
|
||||
total_original_qty = sum(order.original_quantity for order in orders)
|
||||
total_aggregated_qty = sum(order.aggregated_quantity for order in orders)
|
||||
|
||||
overhead_qty = total_aggregated_qty - total_original_qty
|
||||
overhead_percentage = (
|
||||
(overhead_qty / total_original_qty * 100)
|
||||
if total_original_qty > 0 else 0
|
||||
)
|
||||
|
||||
consolidation_ratio = (
|
||||
total_requirements / total_orders
|
||||
if total_orders > 0 else 0
|
||||
)
|
||||
|
||||
return {
|
||||
'total_orders': total_orders,
|
||||
'total_requirements': total_requirements,
|
||||
'consolidation_ratio': float(consolidation_ratio),
|
||||
'orders_with_moq_adjustment': orders_with_moq,
|
||||
'orders_with_package_rounding': orders_with_rounding,
|
||||
'total_original_quantity': float(total_original_qty),
|
||||
'total_aggregated_quantity': float(total_aggregated_qty),
|
||||
'overhead_quantity': float(overhead_qty),
|
||||
'overhead_percentage': float(overhead_percentage)
|
||||
}
|
||||
|
||||
def split_oversized_order(
|
||||
self,
|
||||
order: AggregatedOrder,
|
||||
max_quantity: Decimal,
|
||||
split_window_days: int = 7
|
||||
) -> List[AggregatedOrder]:
|
||||
"""
|
||||
Split an oversized order into multiple smaller orders.
|
||||
|
||||
Args:
|
||||
order: Order to split
|
||||
max_quantity: Maximum quantity per order
|
||||
split_window_days: Days between split orders
|
||||
|
||||
Returns:
|
||||
List of split orders
|
||||
"""
|
||||
if order.aggregated_quantity <= max_quantity:
|
||||
return [order]
|
||||
|
||||
logger.info(
|
||||
f"Splitting oversized order: {order.aggregated_quantity} > {max_quantity}"
|
||||
)
|
||||
|
||||
num_splits = int((order.aggregated_quantity / max_quantity)) + 1
|
||||
qty_per_order = order.aggregated_quantity / Decimal(str(num_splits))
|
||||
|
||||
split_orders = []
|
||||
|
||||
for i in range(num_splits):
|
||||
split_date = order.order_date + timedelta(days=i * split_window_days)
|
||||
|
||||
split_order = AggregatedOrder(
|
||||
id=f"{order.id}_split_{i+1}",
|
||||
supplier_id=order.supplier_id,
|
||||
ingredient_id=order.ingredient_id,
|
||||
ingredient_name=order.ingredient_name,
|
||||
aggregated_quantity=qty_per_order,
|
||||
original_quantity=order.original_quantity / Decimal(str(num_splits)),
|
||||
order_date=split_date,
|
||||
unit_of_measure=order.unit_of_measure,
|
||||
requirements=order.requirements, # Share requirements
|
||||
adjustment_reason=f"Split {i+1}/{num_splits} due to capacity constraint",
|
||||
moq_applied=order.moq_applied,
|
||||
package_rounding_applied=order.package_rounding_applied
|
||||
)
|
||||
|
||||
split_orders.append(split_order)
|
||||
|
||||
return split_orders
|
||||
|
||||
def export_to_dict(self, order: AggregatedOrder) -> Dict:
|
||||
"""
|
||||
Export aggregated order to dictionary.
|
||||
|
||||
Args:
|
||||
order: Aggregated order
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
'id': order.id,
|
||||
'supplier_id': order.supplier_id,
|
||||
'ingredient_id': order.ingredient_id,
|
||||
'ingredient_name': order.ingredient_name,
|
||||
'aggregated_quantity': float(order.aggregated_quantity),
|
||||
'original_quantity': float(order.original_quantity),
|
||||
'order_date': order.order_date.isoformat(),
|
||||
'unit_of_measure': order.unit_of_measure,
|
||||
'num_requirements_aggregated': len(order.requirements),
|
||||
'adjustment_reason': order.adjustment_reason,
|
||||
'moq_applied': order.moq_applied,
|
||||
'package_rounding_applied': order.package_rounding_applied
|
||||
}
|
||||
568
services/procurement/app/services/procurement_service.py
Normal file
568
services/procurement/app/services/procurement_service.py
Normal file
@@ -0,0 +1,568 @@
|
||||
"""
|
||||
Procurement Service - ENHANCED VERSION
|
||||
Integrates advanced replenishment planning with:
|
||||
- Lead-time-aware planning
|
||||
- Dynamic safety stock
|
||||
- Inventory projection
|
||||
- Shelf-life management
|
||||
- MOQ optimization
|
||||
- Multi-criteria supplier selection
|
||||
|
||||
This is a COMPLETE REWRITE integrating all new planning services.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
|
||||
from app.models.replenishment import ReplenishmentPlan, ReplenishmentPlanItem
|
||||
from app.repositories.procurement_plan_repository import ProcurementPlanRepository, ProcurementRequirementRepository
|
||||
from app.schemas.procurement_schemas import (
|
||||
AutoGenerateProcurementRequest, AutoGenerateProcurementResponse
|
||||
)
|
||||
from app.core.config import settings
|
||||
from shared.clients.inventory_client import InventoryServiceClient
|
||||
from shared.clients.forecast_client import ForecastServiceClient
|
||||
from shared.clients.suppliers_client import SuppliersServiceClient
|
||||
from shared.clients.recipes_client import RecipesServiceClient
|
||||
from shared.config.base import BaseServiceSettings
|
||||
from shared.messaging.rabbitmq import RabbitMQClient
|
||||
from shared.monitoring.decorators import monitor_performance
|
||||
from shared.utils.tenant_settings_client import TenantSettingsClient
|
||||
|
||||
# NEW: Import all planning services
|
||||
from app.services.replenishment_planning_service import (
|
||||
ReplenishmentPlanningService,
|
||||
IngredientRequirement
|
||||
)
|
||||
from app.services.moq_aggregator import (
|
||||
MOQAggregator,
|
||||
ProcurementRequirement as MOQProcurementRequirement,
|
||||
SupplierConstraints
|
||||
)
|
||||
from app.services.supplier_selector import (
|
||||
SupplierSelector,
|
||||
SupplierOption
|
||||
)
|
||||
from app.services.recipe_explosion_service import RecipeExplosionService
|
||||
from app.services.smart_procurement_calculator import SmartProcurementCalculator
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ProcurementService:
|
||||
"""
|
||||
Enhanced Procurement Service with Advanced Planning
|
||||
|
||||
NEW WORKFLOW:
|
||||
1. Generate forecast (from Orchestrator)
|
||||
2. Get current inventory
|
||||
3. Build ingredient requirements
|
||||
4. Generate replenishment plan (NEW - with all planning algorithms)
|
||||
5. Apply MOQ aggregation (NEW)
|
||||
6. Select suppliers (NEW - multi-criteria)
|
||||
7. Create purchase orders
|
||||
8. Save everything to database
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
config: BaseServiceSettings,
|
||||
inventory_client: Optional[InventoryServiceClient] = None,
|
||||
forecast_client: Optional[ForecastServiceClient] = None,
|
||||
suppliers_client: Optional[SuppliersServiceClient] = None,
|
||||
recipes_client: Optional[RecipesServiceClient] = None,
|
||||
):
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.plan_repo = ProcurementPlanRepository(db)
|
||||
self.requirement_repo = ProcurementRequirementRepository(db)
|
||||
|
||||
# Initialize service clients
|
||||
self.inventory_client = inventory_client or InventoryServiceClient(config)
|
||||
self.forecast_client = forecast_client or ForecastServiceClient(config, "procurement-service")
|
||||
self.suppliers_client = suppliers_client or SuppliersServiceClient(config)
|
||||
self.recipes_client = recipes_client or RecipesServiceClient(config)
|
||||
|
||||
# Initialize tenant settings client
|
||||
tenant_service_url = getattr(config, 'TENANT_SERVICE_URL', 'http://tenant-service:8000')
|
||||
self.tenant_settings_client = TenantSettingsClient(tenant_service_url=tenant_service_url)
|
||||
|
||||
# Initialize RabbitMQ client
|
||||
rabbitmq_url = getattr(config, 'RABBITMQ_URL', 'amqp://guest:guest@localhost:5672/')
|
||||
self.rabbitmq_client = RabbitMQClient(rabbitmq_url, "procurement-service")
|
||||
|
||||
# Initialize Recipe Explosion Service
|
||||
self.recipe_explosion_service = RecipeExplosionService(
|
||||
config=config,
|
||||
recipes_client=self.recipes_client,
|
||||
inventory_client=self.inventory_client
|
||||
)
|
||||
|
||||
# Initialize Smart Calculator (keep for backward compatibility)
|
||||
self.smart_calculator = SmartProcurementCalculator(
|
||||
inventory_client=self.inventory_client,
|
||||
forecast_client=self.forecast_client
|
||||
)
|
||||
|
||||
# NEW: Initialize advanced planning services
|
||||
self.replenishment_planner = ReplenishmentPlanningService(
|
||||
projection_horizon_days=getattr(settings, 'REPLENISHMENT_PROJECTION_HORIZON_DAYS', 7),
|
||||
default_service_level=getattr(settings, 'REPLENISHMENT_SERVICE_LEVEL', 0.95),
|
||||
default_buffer_days=getattr(settings, 'REPLENISHMENT_BUFFER_DAYS', 1)
|
||||
)
|
||||
|
||||
self.moq_aggregator = MOQAggregator(
|
||||
consolidation_window_days=getattr(settings, 'MOQ_CONSOLIDATION_WINDOW_DAYS', 7),
|
||||
allow_early_ordering=getattr(settings, 'MOQ_ALLOW_EARLY_ORDERING', True)
|
||||
)
|
||||
|
||||
self.supplier_selector = SupplierSelector(
|
||||
price_weight=getattr(settings, 'SUPPLIER_PRICE_WEIGHT', 0.40),
|
||||
lead_time_weight=getattr(settings, 'SUPPLIER_LEAD_TIME_WEIGHT', 0.20),
|
||||
quality_weight=getattr(settings, 'SUPPLIER_QUALITY_WEIGHT', 0.20),
|
||||
reliability_weight=getattr(settings, 'SUPPLIER_RELIABILITY_WEIGHT', 0.20),
|
||||
diversification_threshold=getattr(settings, 'SUPPLIER_DIVERSIFICATION_THRESHOLD', Decimal('1000')),
|
||||
max_single_supplier_percentage=getattr(settings, 'SUPPLIER_MAX_SINGLE_PERCENTAGE', 0.70)
|
||||
)
|
||||
|
||||
logger.info("ProcurementServiceEnhanced initialized with advanced planning")
|
||||
|
||||
@monitor_performance("auto_generate_procurement_enhanced")
|
||||
async def auto_generate_procurement(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
request: AutoGenerateProcurementRequest
|
||||
) -> AutoGenerateProcurementResponse:
|
||||
"""
|
||||
Auto-generate procurement plan with ADVANCED PLANNING
|
||||
|
||||
NEW WORKFLOW (vs old):
|
||||
OLD: Forecast → Simple stock check → Create POs
|
||||
NEW: Forecast → Replenishment Planning → MOQ Optimization → Supplier Selection → Create POs
|
||||
"""
|
||||
try:
|
||||
target_date = request.target_date or date.today()
|
||||
forecast_data = request.forecast_data
|
||||
|
||||
logger.info("Starting ENHANCED auto-generate procurement",
|
||||
tenant_id=tenant_id,
|
||||
target_date=target_date,
|
||||
has_forecast_data=bool(forecast_data))
|
||||
|
||||
# ============================================================
|
||||
# STEP 1: Get Current Inventory (Use cached if available)
|
||||
# ============================================================
|
||||
if request.inventory_data:
|
||||
# Use cached inventory from Orchestrator (NEW)
|
||||
inventory_items = request.inventory_data.get('ingredients', [])
|
||||
logger.info(f"Using cached inventory snapshot: {len(inventory_items)} items")
|
||||
else:
|
||||
# Fallback: Fetch from Inventory Service
|
||||
inventory_items = await self._get_inventory_list(tenant_id)
|
||||
logger.info(f"Fetched inventory from service: {len(inventory_items)} items")
|
||||
|
||||
if not inventory_items:
|
||||
return AutoGenerateProcurementResponse(
|
||||
success=False,
|
||||
message="No inventory items found",
|
||||
errors=["Unable to retrieve inventory data"]
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# STEP 2: Get All Suppliers (Use cached if available)
|
||||
# ============================================================
|
||||
if request.suppliers_data:
|
||||
# Use cached suppliers from Orchestrator (NEW)
|
||||
suppliers = request.suppliers_data.get('suppliers', [])
|
||||
logger.info(f"Using cached suppliers snapshot: {len(suppliers)} suppliers")
|
||||
else:
|
||||
# Fallback: Fetch from Suppliers Service
|
||||
suppliers = await self._get_all_suppliers(tenant_id)
|
||||
logger.info(f"Fetched suppliers from service: {len(suppliers)} suppliers")
|
||||
|
||||
# ============================================================
|
||||
# STEP 3: Parse Forecast Data
|
||||
# ============================================================
|
||||
forecasts = self._parse_forecast_data(forecast_data, inventory_items)
|
||||
logger.info(f"Parsed {len(forecasts)} forecast items")
|
||||
|
||||
# ============================================================
|
||||
# STEP 4: Build Ingredient Requirements
|
||||
# ============================================================
|
||||
ingredient_requirements = await self._build_ingredient_requirements(
|
||||
tenant_id=tenant_id,
|
||||
forecasts=forecasts,
|
||||
inventory_items=inventory_items,
|
||||
suppliers=suppliers,
|
||||
target_date=target_date
|
||||
)
|
||||
|
||||
if not ingredient_requirements:
|
||||
logger.warning("No ingredient requirements generated")
|
||||
return AutoGenerateProcurementResponse(
|
||||
success=False,
|
||||
message="No procurement requirements identified",
|
||||
errors=["No items need replenishment"]
|
||||
)
|
||||
|
||||
logger.info(f"Built {len(ingredient_requirements)} ingredient requirements")
|
||||
|
||||
# ============================================================
|
||||
# STEP 5: Generate Replenishment Plan (NEW!)
|
||||
# ============================================================
|
||||
replenishment_plan = await self.replenishment_planner.generate_replenishment_plan(
|
||||
tenant_id=str(tenant_id),
|
||||
requirements=ingredient_requirements,
|
||||
forecast_id=forecast_data.get('forecast_id'),
|
||||
production_schedule_id=request.production_schedule_id
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Replenishment plan generated: {replenishment_plan.total_items} items, "
|
||||
f"{replenishment_plan.urgent_items} urgent, "
|
||||
f"{replenishment_plan.high_risk_items} high risk"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# STEP 6: Apply MOQ Aggregation (NEW!)
|
||||
# ============================================================
|
||||
moq_requirements, supplier_constraints = self._prepare_moq_inputs(
|
||||
replenishment_plan,
|
||||
suppliers
|
||||
)
|
||||
|
||||
aggregated_orders = self.moq_aggregator.aggregate_requirements(
|
||||
requirements=moq_requirements,
|
||||
supplier_constraints=supplier_constraints
|
||||
)
|
||||
|
||||
moq_efficiency = self.moq_aggregator.calculate_order_efficiency(aggregated_orders)
|
||||
logger.info(
|
||||
f"MOQ aggregation: {len(aggregated_orders)} aggregated orders from "
|
||||
f"{len(moq_requirements)} requirements "
|
||||
f"(consolidation ratio: {moq_efficiency['consolidation_ratio']:.2f})"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# STEP 7: Multi-Criteria Supplier Selection (NEW!)
|
||||
# ============================================================
|
||||
supplier_selections = await self._select_suppliers_for_requirements(
|
||||
replenishment_plan,
|
||||
suppliers
|
||||
)
|
||||
|
||||
logger.info(f"Supplier selection completed for {len(supplier_selections)} items")
|
||||
|
||||
# ============================================================
|
||||
# STEP 8: Save to Database
|
||||
# ============================================================
|
||||
# Create traditional procurement plan
|
||||
plan_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'plan_number': await self._generate_plan_number(),
|
||||
'plan_date': target_date,
|
||||
'planning_horizon_days': request.planning_horizon_days,
|
||||
'status': 'draft',
|
||||
'forecast_id': forecast_data.get('forecast_id'),
|
||||
'production_schedule_id': request.production_schedule_id,
|
||||
'total_estimated_cost': replenishment_plan.total_estimated_cost,
|
||||
'seasonality_adjustment': Decimal('1.0')
|
||||
}
|
||||
|
||||
plan = await self.plan_repo.create_plan(plan_data)
|
||||
|
||||
# Create procurement requirements from replenishment plan
|
||||
requirements_data = self._convert_replenishment_to_requirements(
|
||||
plan_id=plan.id,
|
||||
tenant_id=tenant_id,
|
||||
replenishment_plan=replenishment_plan,
|
||||
supplier_selections=supplier_selections
|
||||
)
|
||||
|
||||
# Save requirements
|
||||
created_requirements = await self.requirement_repo.create_requirements_batch(requirements_data)
|
||||
|
||||
# Update plan totals
|
||||
await self.plan_repo.update_plan(plan.id, tenant_id, {
|
||||
'total_requirements': len(requirements_data),
|
||||
'primary_suppliers_count': len(set(
|
||||
r.get('preferred_supplier_id') for r in requirements_data
|
||||
if r.get('preferred_supplier_id')
|
||||
)),
|
||||
'supplier_diversification_score': moq_efficiency.get('consolidation_ratio', 1.0)
|
||||
})
|
||||
|
||||
# ============================================================
|
||||
# STEP 9: Optionally Create Purchase Orders
|
||||
# ============================================================
|
||||
created_pos = []
|
||||
if request.auto_create_pos:
|
||||
po_result = await self._create_purchase_orders_from_plan(
|
||||
tenant_id=tenant_id,
|
||||
plan_id=plan.id,
|
||||
auto_approve=request.auto_approve_pos
|
||||
)
|
||||
if po_result.get('success'):
|
||||
created_pos = po_result.get('created_pos', [])
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
# ============================================================
|
||||
# STEP 10: Publish Events
|
||||
# ============================================================
|
||||
await self._publish_plan_generated_event(tenant_id, plan.id)
|
||||
|
||||
logger.info(
|
||||
"ENHANCED procurement plan completed successfully",
|
||||
tenant_id=tenant_id,
|
||||
plan_id=plan.id,
|
||||
requirements_count=len(requirements_data),
|
||||
pos_created=len(created_pos),
|
||||
urgent_items=replenishment_plan.urgent_items,
|
||||
high_risk_items=replenishment_plan.high_risk_items
|
||||
)
|
||||
|
||||
return AutoGenerateProcurementResponse(
|
||||
success=True,
|
||||
message="Enhanced procurement plan generated successfully",
|
||||
plan_id=plan.id,
|
||||
plan_number=plan.plan_number,
|
||||
requirements_created=len(requirements_data),
|
||||
purchase_orders_created=len(created_pos),
|
||||
purchase_orders_auto_approved=sum(1 for po in created_pos if po.get('auto_approved')),
|
||||
total_estimated_cost=replenishment_plan.total_estimated_cost,
|
||||
created_pos=created_pos
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error in enhanced auto_generate_procurement",
|
||||
error=str(e), tenant_id=tenant_id, exc_info=True)
|
||||
return AutoGenerateProcurementResponse(
|
||||
success=False,
|
||||
message="Failed to generate enhanced procurement plan",
|
||||
errors=[str(e)]
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# Helper Methods
|
||||
# ============================================================
|
||||
|
||||
async def _build_ingredient_requirements(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
forecasts: List[Dict],
|
||||
inventory_items: List[Dict],
|
||||
suppliers: List[Dict],
|
||||
target_date: date
|
||||
) -> List[IngredientRequirement]:
|
||||
"""
|
||||
Build ingredient requirements from forecasts
|
||||
"""
|
||||
requirements = []
|
||||
|
||||
for forecast in forecasts:
|
||||
ingredient_id = forecast.get('ingredient_id')
|
||||
ingredient = next((i for i in inventory_items if str(i['id']) == str(ingredient_id)), None)
|
||||
|
||||
if not ingredient:
|
||||
continue
|
||||
|
||||
# Calculate required quantity
|
||||
predicted_demand = Decimal(str(forecast.get('predicted_demand', 0)))
|
||||
current_stock = Decimal(str(ingredient.get('quantity', 0)))
|
||||
|
||||
if predicted_demand > current_stock:
|
||||
required_quantity = predicted_demand - current_stock
|
||||
|
||||
# Find preferred supplier
|
||||
preferred_supplier = self._find_preferred_supplier(ingredient, suppliers)
|
||||
|
||||
# Get lead time
|
||||
lead_time_days = preferred_supplier.get('lead_time_days', 3) if preferred_supplier else 3
|
||||
|
||||
# Build requirement
|
||||
req = IngredientRequirement(
|
||||
ingredient_id=str(ingredient_id),
|
||||
ingredient_name=ingredient.get('name', 'Unknown'),
|
||||
required_quantity=required_quantity,
|
||||
required_by_date=target_date + timedelta(days=7),
|
||||
supplier_id=str(preferred_supplier['id']) if preferred_supplier else None,
|
||||
lead_time_days=lead_time_days,
|
||||
shelf_life_days=ingredient.get('shelf_life_days'),
|
||||
is_perishable=ingredient.get('category') in ['fresh', 'dairy', 'produce'],
|
||||
category=ingredient.get('category', 'dry'),
|
||||
unit_of_measure=ingredient.get('unit_of_measure', 'kg'),
|
||||
current_stock=current_stock,
|
||||
daily_consumption_rate=float(predicted_demand) / 7, # Estimate
|
||||
demand_std_dev=float(forecast.get('confidence_score', 0)) * 10 # Rough estimate
|
||||
)
|
||||
|
||||
requirements.append(req)
|
||||
|
||||
return requirements
|
||||
|
||||
def _prepare_moq_inputs(
|
||||
self,
|
||||
replenishment_plan,
|
||||
suppliers: List[Dict]
|
||||
) -> Tuple[List[MOQProcurementRequirement], Dict[str, SupplierConstraints]]:
|
||||
"""
|
||||
Prepare inputs for MOQ aggregator
|
||||
"""
|
||||
moq_requirements = []
|
||||
supplier_constraints = {}
|
||||
|
||||
for item in replenishment_plan.items:
|
||||
req = MOQProcurementRequirement(
|
||||
id=str(item.id),
|
||||
ingredient_id=item.ingredient_id,
|
||||
ingredient_name=item.ingredient_name,
|
||||
quantity=item.final_order_quantity,
|
||||
required_date=item.required_by_date,
|
||||
supplier_id=item.supplier_id or 'unknown',
|
||||
unit_of_measure=item.unit_of_measure
|
||||
)
|
||||
moq_requirements.append(req)
|
||||
|
||||
# Build supplier constraints
|
||||
for supplier in suppliers:
|
||||
supplier_id = str(supplier['id'])
|
||||
supplier_constraints[supplier_id] = SupplierConstraints(
|
||||
supplier_id=supplier_id,
|
||||
supplier_name=supplier.get('name', 'Unknown'),
|
||||
min_order_quantity=Decimal(str(supplier.get('min_order_quantity', 0))) if supplier.get('min_order_quantity') else None,
|
||||
min_order_value=Decimal(str(supplier.get('min_order_value', 0))) if supplier.get('min_order_value') else None,
|
||||
package_size=None, # Not in current schema
|
||||
max_order_quantity=None # Not in current schema
|
||||
)
|
||||
|
||||
return moq_requirements, supplier_constraints
|
||||
|
||||
async def _select_suppliers_for_requirements(
|
||||
self,
|
||||
replenishment_plan,
|
||||
suppliers: List[Dict]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Select best suppliers for each requirement
|
||||
"""
|
||||
selections = {}
|
||||
|
||||
for item in replenishment_plan.items:
|
||||
# Build supplier options
|
||||
supplier_options = []
|
||||
for supplier in suppliers:
|
||||
option = SupplierOption(
|
||||
supplier_id=str(supplier['id']),
|
||||
supplier_name=supplier.get('name', 'Unknown'),
|
||||
unit_price=Decimal(str(supplier.get('unit_price', 10))), # Default price
|
||||
lead_time_days=supplier.get('lead_time_days', 3),
|
||||
min_order_quantity=Decimal(str(supplier.get('min_order_quantity', 0))) if supplier.get('min_order_quantity') else None,
|
||||
quality_score=0.85, # Default quality
|
||||
reliability_score=0.90 # Default reliability
|
||||
)
|
||||
supplier_options.append(option)
|
||||
|
||||
if supplier_options:
|
||||
# Select suppliers
|
||||
result = self.supplier_selector.select_suppliers(
|
||||
ingredient_id=item.ingredient_id,
|
||||
ingredient_name=item.ingredient_name,
|
||||
required_quantity=item.final_order_quantity,
|
||||
supplier_options=supplier_options
|
||||
)
|
||||
|
||||
selections[item.ingredient_id] = result
|
||||
|
||||
return selections
|
||||
|
||||
def _convert_replenishment_to_requirements(
|
||||
self,
|
||||
plan_id: uuid.UUID,
|
||||
tenant_id: uuid.UUID,
|
||||
replenishment_plan,
|
||||
supplier_selections: Dict
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Convert replenishment plan items to procurement requirements
|
||||
"""
|
||||
requirements_data = []
|
||||
|
||||
for item in replenishment_plan.items:
|
||||
# Get supplier selection
|
||||
selection = supplier_selections.get(item.ingredient_id)
|
||||
primary_allocation = selection.allocations[0] if selection and selection.allocations else None
|
||||
|
||||
req_data = {
|
||||
'procurement_plan_id': plan_id,
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': uuid.UUID(item.ingredient_id),
|
||||
'ingredient_name': item.ingredient_name,
|
||||
'required_quantity': item.final_order_quantity,
|
||||
'unit_of_measure': item.unit_of_measure,
|
||||
'estimated_unit_price': primary_allocation.unit_price if primary_allocation else Decimal('10'),
|
||||
'estimated_total_cost': primary_allocation.total_cost if primary_allocation else item.final_order_quantity * Decimal('10'),
|
||||
'required_by_date': item.required_by_date,
|
||||
'priority': 'urgent' if item.is_urgent else 'normal',
|
||||
'preferred_supplier_id': uuid.UUID(primary_allocation.supplier_id) if primary_allocation else None,
|
||||
'calculation_method': 'ENHANCED_REPLENISHMENT_PLANNING',
|
||||
'ai_suggested_quantity': item.base_quantity,
|
||||
'adjusted_quantity': item.final_order_quantity,
|
||||
'adjustment_reason': f"Safety stock: {item.safety_stock_quantity}, Shelf-life adjusted",
|
||||
'lead_time_days': item.lead_time_days
|
||||
}
|
||||
|
||||
requirements_data.append(req_data)
|
||||
|
||||
return requirements_data
|
||||
|
||||
# Additional helper methods (shortened for brevity)
|
||||
async def _get_inventory_list(self, tenant_id):
|
||||
"""Get inventory items"""
|
||||
return await self.inventory_client.get_ingredients(str(tenant_id))
|
||||
|
||||
async def _get_all_suppliers(self, tenant_id):
|
||||
"""Get all suppliers"""
|
||||
return await self.suppliers_client.get_suppliers(str(tenant_id))
|
||||
|
||||
def _parse_forecast_data(self, forecast_data, inventory_items):
|
||||
"""Parse forecast data from orchestrator"""
|
||||
forecasts = forecast_data.get('forecasts', [])
|
||||
return forecasts
|
||||
|
||||
def _find_preferred_supplier(self, ingredient, suppliers):
|
||||
"""Find preferred supplier for ingredient"""
|
||||
# Simple: return first supplier (can be enhanced with logic)
|
||||
return suppliers[0] if suppliers else None
|
||||
|
||||
async def _generate_plan_number(self):
|
||||
"""Generate unique plan number"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
return f"PLAN-{timestamp}"
|
||||
|
||||
async def _create_purchase_orders_from_plan(self, tenant_id, plan_id, auto_approve):
|
||||
"""Create POs from plan (placeholder)"""
|
||||
return {'success': True, 'created_pos': []}
|
||||
|
||||
async def _publish_plan_generated_event(self, tenant_id, plan_id):
|
||||
"""Publish plan generated event"""
|
||||
try:
|
||||
await self.rabbitmq_client.publish_event(
|
||||
exchange='procurement',
|
||||
routing_key='plan.generated',
|
||||
message={
|
||||
'tenant_id': str(tenant_id),
|
||||
'plan_id': str(plan_id),
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to publish event: {e}")
|
||||
652
services/procurement/app/services/purchase_order_service.py
Normal file
652
services/procurement/app/services/purchase_order_service.py
Normal file
@@ -0,0 +1,652 @@
|
||||
# ================================================================
|
||||
# services/procurement/app/services/purchase_order_service.py
|
||||
# ================================================================
|
||||
"""
|
||||
Purchase Order Service - Business logic for purchase order management
|
||||
Migrated from Suppliers Service to Procurement Service ownership
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem, Delivery, DeliveryItem, SupplierInvoice
|
||||
from app.repositories.purchase_order_repository import (
|
||||
PurchaseOrderRepository,
|
||||
PurchaseOrderItemRepository,
|
||||
DeliveryRepository,
|
||||
SupplierInvoiceRepository
|
||||
)
|
||||
from app.schemas.purchase_order_schemas import (
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderUpdate,
|
||||
PurchaseOrderResponse,
|
||||
DeliveryCreate,
|
||||
DeliveryUpdate,
|
||||
SupplierInvoiceCreate,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from shared.clients.suppliers_client import SuppliersServiceClient
|
||||
from shared.config.base import BaseServiceSettings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class PurchaseOrderService:
|
||||
"""Service for purchase order management operations"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
config: BaseServiceSettings,
|
||||
suppliers_client: Optional[SuppliersServiceClient] = None
|
||||
):
|
||||
self.db = db
|
||||
self.config = config
|
||||
self.po_repo = PurchaseOrderRepository(db)
|
||||
self.item_repo = PurchaseOrderItemRepository(db)
|
||||
self.delivery_repo = DeliveryRepository(db)
|
||||
self.invoice_repo = SupplierInvoiceRepository(db)
|
||||
|
||||
# Initialize suppliers client for supplier validation
|
||||
self.suppliers_client = suppliers_client or SuppliersServiceClient(config)
|
||||
|
||||
# ================================================================
|
||||
# PURCHASE ORDER CRUD
|
||||
# ================================================================
|
||||
|
||||
async def create_purchase_order(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_data: PurchaseOrderCreate,
|
||||
created_by: Optional[uuid.UUID] = None
|
||||
) -> PurchaseOrder:
|
||||
"""
|
||||
Create a new purchase order with items
|
||||
|
||||
Flow:
|
||||
1. Validate supplier exists and is active
|
||||
2. Generate PO number
|
||||
3. Calculate totals
|
||||
4. Determine approval requirements
|
||||
5. Create PO and items
|
||||
6. Link to procurement plan if provided
|
||||
"""
|
||||
try:
|
||||
logger.info("Creating purchase order",
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=po_data.supplier_id)
|
||||
|
||||
# Validate supplier
|
||||
supplier = await self._get_and_validate_supplier(tenant_id, po_data.supplier_id)
|
||||
|
||||
# Generate PO number
|
||||
po_number = await self.po_repo.generate_po_number(tenant_id)
|
||||
|
||||
# Calculate totals
|
||||
subtotal = po_data.subtotal
|
||||
total_amount = (
|
||||
subtotal +
|
||||
po_data.tax_amount +
|
||||
po_data.shipping_cost -
|
||||
po_data.discount_amount
|
||||
)
|
||||
|
||||
# Determine approval requirements
|
||||
requires_approval = self._requires_approval(total_amount, po_data.priority)
|
||||
initial_status = self._determine_initial_status(total_amount, requires_approval)
|
||||
|
||||
# Set delivery date if not provided
|
||||
required_delivery_date = po_data.required_delivery_date
|
||||
estimated_delivery_date = date.today() + timedelta(days=supplier.get('standard_lead_time', 7))
|
||||
|
||||
# Create PO
|
||||
po_create_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'supplier_id': po_data.supplier_id,
|
||||
'po_number': po_number,
|
||||
'status': initial_status,
|
||||
'priority': po_data.priority,
|
||||
'order_date': datetime.utcnow(),
|
||||
'required_delivery_date': required_delivery_date,
|
||||
'estimated_delivery_date': estimated_delivery_date,
|
||||
'subtotal': subtotal,
|
||||
'tax_amount': po_data.tax_amount,
|
||||
'shipping_cost': po_data.shipping_cost,
|
||||
'discount_amount': po_data.discount_amount,
|
||||
'total_amount': total_amount,
|
||||
'currency': supplier.get('currency', 'EUR'),
|
||||
'requires_approval': requires_approval,
|
||||
'notes': po_data.notes,
|
||||
'procurement_plan_id': po_data.procurement_plan_id,
|
||||
'created_by': created_by,
|
||||
'updated_by': created_by,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow(),
|
||||
}
|
||||
|
||||
purchase_order = await self.po_repo.create_po(po_create_data)
|
||||
|
||||
# Create PO items
|
||||
for item_data in po_data.items:
|
||||
item_create_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'purchase_order_id': purchase_order.id,
|
||||
'inventory_product_id': item_data.inventory_product_id,
|
||||
'ordered_quantity': item_data.ordered_quantity,
|
||||
'unit_price': item_data.unit_price,
|
||||
'unit_of_measure': item_data.unit_of_measure,
|
||||
'line_total': item_data.ordered_quantity * item_data.unit_price,
|
||||
'received_quantity': Decimal('0'),
|
||||
'quality_requirements': item_data.quality_requirements,
|
||||
'item_notes': item_data.item_notes,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow(),
|
||||
}
|
||||
|
||||
await self.item_repo.create_item(item_create_data)
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Purchase order created successfully",
|
||||
tenant_id=tenant_id,
|
||||
po_id=purchase_order.id,
|
||||
po_number=po_number,
|
||||
total_amount=float(total_amount))
|
||||
|
||||
return purchase_order
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error creating purchase order", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def get_purchase_order(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_id: uuid.UUID
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Get purchase order by ID with items"""
|
||||
try:
|
||||
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
|
||||
if po:
|
||||
# Enrich with supplier information
|
||||
await self._enrich_po_with_supplier(tenant_id, po)
|
||||
return po
|
||||
except Exception as e:
|
||||
logger.error("Error getting purchase order", error=str(e), po_id=po_id)
|
||||
return None
|
||||
|
||||
async def list_purchase_orders(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
supplier_id: Optional[uuid.UUID] = None,
|
||||
status: Optional[str] = None
|
||||
) -> List[PurchaseOrder]:
|
||||
"""List purchase orders with filters"""
|
||||
try:
|
||||
# Convert status string to enum if provided
|
||||
status_enum = None
|
||||
if status:
|
||||
try:
|
||||
from app.models.purchase_order import PurchaseOrderStatus
|
||||
# Convert from UPPERCASE to lowercase for enum lookup
|
||||
status_enum = PurchaseOrderStatus[status.lower()]
|
||||
except (KeyError, AttributeError):
|
||||
logger.warning("Invalid status value provided", status=status)
|
||||
status_enum = None
|
||||
|
||||
pos = await self.po_repo.list_purchase_orders(
|
||||
tenant_id=tenant_id,
|
||||
offset=skip, # Repository uses 'offset' parameter
|
||||
limit=limit,
|
||||
supplier_id=supplier_id,
|
||||
status=status_enum
|
||||
)
|
||||
|
||||
# Enrich with supplier information
|
||||
for po in pos:
|
||||
await self._enrich_po_with_supplier(tenant_id, po)
|
||||
|
||||
return pos
|
||||
except Exception as e:
|
||||
logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
async def update_po(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_id: uuid.UUID,
|
||||
po_data: PurchaseOrderUpdate,
|
||||
updated_by: Optional[uuid.UUID] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Update purchase order information"""
|
||||
try:
|
||||
logger.info("Updating purchase order", po_id=po_id)
|
||||
|
||||
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
# Check if order can be modified
|
||||
if po.status in ['completed', 'cancelled']:
|
||||
raise ValueError("Cannot modify completed or cancelled orders")
|
||||
|
||||
# Prepare update data
|
||||
update_data = po_data.model_dump(exclude_unset=True)
|
||||
update_data['updated_by'] = updated_by
|
||||
update_data['updated_at'] = datetime.utcnow()
|
||||
|
||||
# Recalculate totals if financial fields changed
|
||||
if any(key in update_data for key in ['tax_amount', 'shipping_cost', 'discount_amount']):
|
||||
total_amount = (
|
||||
po.subtotal +
|
||||
update_data.get('tax_amount', po.tax_amount) +
|
||||
update_data.get('shipping_cost', po.shipping_cost) -
|
||||
update_data.get('discount_amount', po.discount_amount)
|
||||
)
|
||||
update_data['total_amount'] = total_amount
|
||||
|
||||
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Purchase order updated successfully", po_id=po_id)
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error updating purchase order", error=str(e), po_id=po_id)
|
||||
raise
|
||||
|
||||
async def update_order_status(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_id: uuid.UUID,
|
||||
status: str,
|
||||
updated_by: Optional[uuid.UUID] = None,
|
||||
notes: Optional[str] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Update purchase order status"""
|
||||
try:
|
||||
logger.info("Updating PO status", po_id=po_id, status=status)
|
||||
|
||||
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
# Validate status transition
|
||||
if not self._is_valid_status_transition(po.status, status):
|
||||
raise ValueError(f"Invalid status transition from {po.status} to {status}")
|
||||
|
||||
update_data = {
|
||||
'status': status,
|
||||
'updated_by': updated_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
if status == 'sent_to_supplier':
|
||||
update_data['sent_to_supplier_at'] = datetime.utcnow()
|
||||
elif status == 'confirmed':
|
||||
update_data['supplier_confirmation_date'] = datetime.utcnow()
|
||||
|
||||
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
|
||||
await self.db.commit()
|
||||
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error updating PO status", error=str(e), po_id=po_id)
|
||||
raise
|
||||
|
||||
async def approve_purchase_order(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_id: uuid.UUID,
|
||||
approved_by: uuid.UUID,
|
||||
approval_notes: Optional[str] = None
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Approve a purchase order"""
|
||||
try:
|
||||
logger.info("Approving purchase order", po_id=po_id)
|
||||
|
||||
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
if po.status not in ['draft', 'pending_approval']:
|
||||
raise ValueError(f"Cannot approve order with status {po.status}")
|
||||
|
||||
update_data = {
|
||||
'status': 'approved',
|
||||
'approved_by': approved_by,
|
||||
'approved_at': datetime.utcnow(),
|
||||
'updated_by': approved_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Purchase order approved successfully", po_id=po_id)
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error approving purchase order", error=str(e), po_id=po_id)
|
||||
raise
|
||||
|
||||
async def reject_purchase_order(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_id: uuid.UUID,
|
||||
rejected_by: uuid.UUID,
|
||||
rejection_reason: str
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Reject a purchase order"""
|
||||
try:
|
||||
logger.info("Rejecting purchase order", po_id=po_id)
|
||||
|
||||
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
if po.status not in ['draft', 'pending_approval']:
|
||||
raise ValueError(f"Cannot reject order with status {po.status}")
|
||||
|
||||
update_data = {
|
||||
'status': 'rejected',
|
||||
'rejection_reason': rejection_reason,
|
||||
'updated_by': rejected_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Purchase order rejected", po_id=po_id)
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error rejecting purchase order", error=str(e), po_id=po_id)
|
||||
raise
|
||||
|
||||
async def cancel_purchase_order(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
po_id: uuid.UUID,
|
||||
cancelled_by: uuid.UUID,
|
||||
cancellation_reason: str
|
||||
) -> Optional[PurchaseOrder]:
|
||||
"""Cancel a purchase order"""
|
||||
try:
|
||||
logger.info("Cancelling purchase order", po_id=po_id)
|
||||
|
||||
po = await self.po_repo.get_po_by_id(po_id, tenant_id)
|
||||
if not po:
|
||||
return None
|
||||
|
||||
if po.status in ['completed', 'cancelled']:
|
||||
raise ValueError(f"Cannot cancel order with status {po.status}")
|
||||
|
||||
update_data = {
|
||||
'status': 'cancelled',
|
||||
'notes': f"{po.notes or ''}\nCancellation: {cancellation_reason}",
|
||||
'updated_by': cancelled_by,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
po = await self.po_repo.update_po(po_id, tenant_id, update_data)
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Purchase order cancelled", po_id=po_id)
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error cancelling purchase order", error=str(e), po_id=po_id)
|
||||
raise
|
||||
|
||||
# ================================================================
|
||||
# DELIVERY MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
async def create_delivery(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
delivery_data: DeliveryCreate,
|
||||
created_by: uuid.UUID
|
||||
) -> Delivery:
|
||||
"""Create a delivery record for a purchase order"""
|
||||
try:
|
||||
logger.info("Creating delivery", tenant_id=tenant_id, po_id=delivery_data.purchase_order_id)
|
||||
|
||||
# Validate PO exists
|
||||
po = await self.po_repo.get_po_by_id(delivery_data.purchase_order_id, tenant_id)
|
||||
if not po:
|
||||
raise ValueError("Purchase order not found")
|
||||
|
||||
# Generate delivery number
|
||||
delivery_number = await self.delivery_repo.generate_delivery_number(tenant_id)
|
||||
|
||||
# Create delivery
|
||||
delivery_create_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'purchase_order_id': delivery_data.purchase_order_id,
|
||||
'supplier_id': delivery_data.supplier_id,
|
||||
'delivery_number': delivery_number,
|
||||
'supplier_delivery_note': delivery_data.supplier_delivery_note,
|
||||
'status': 'scheduled',
|
||||
'scheduled_date': delivery_data.scheduled_date,
|
||||
'estimated_arrival': delivery_data.estimated_arrival,
|
||||
'carrier_name': delivery_data.carrier_name,
|
||||
'tracking_number': delivery_data.tracking_number,
|
||||
'notes': delivery_data.notes,
|
||||
'created_by': created_by,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow(),
|
||||
}
|
||||
|
||||
delivery = await self.delivery_repo.create_delivery(delivery_create_data)
|
||||
|
||||
# Create delivery items
|
||||
for item_data in delivery_data.items:
|
||||
item_create_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'delivery_id': delivery.id,
|
||||
'purchase_order_item_id': item_data.purchase_order_item_id,
|
||||
'inventory_product_id': item_data.inventory_product_id,
|
||||
'ordered_quantity': item_data.ordered_quantity,
|
||||
'delivered_quantity': item_data.delivered_quantity,
|
||||
'accepted_quantity': item_data.accepted_quantity,
|
||||
'rejected_quantity': item_data.rejected_quantity,
|
||||
'batch_lot_number': item_data.batch_lot_number,
|
||||
'expiry_date': item_data.expiry_date,
|
||||
'quality_grade': item_data.quality_grade,
|
||||
'quality_issues': item_data.quality_issues,
|
||||
'rejection_reason': item_data.rejection_reason,
|
||||
'item_notes': item_data.item_notes,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow(),
|
||||
}
|
||||
|
||||
await self.delivery_repo.create_delivery_item(item_create_data)
|
||||
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Delivery created successfully",
|
||||
tenant_id=tenant_id,
|
||||
delivery_id=delivery.id,
|
||||
delivery_number=delivery_number)
|
||||
|
||||
return delivery
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error creating delivery", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
async def update_delivery_status(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
delivery_id: uuid.UUID,
|
||||
status: str,
|
||||
updated_by: uuid.UUID
|
||||
) -> Optional[Delivery]:
|
||||
"""Update delivery status"""
|
||||
try:
|
||||
update_data = {
|
||||
'status': status,
|
||||
'updated_at': datetime.utcnow()
|
||||
}
|
||||
|
||||
if status == 'in_transit':
|
||||
update_data['actual_arrival'] = None
|
||||
elif status == 'delivered':
|
||||
update_data['actual_arrival'] = datetime.utcnow()
|
||||
elif status == 'completed':
|
||||
update_data['completed_at'] = datetime.utcnow()
|
||||
|
||||
delivery = await self.delivery_repo.update_delivery(delivery_id, tenant_id, update_data)
|
||||
await self.db.commit()
|
||||
|
||||
return delivery
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error updating delivery status", error=str(e), delivery_id=delivery_id)
|
||||
raise
|
||||
|
||||
# ================================================================
|
||||
# INVOICE MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
tenant_id: uuid.UUID,
|
||||
invoice_data: SupplierInvoiceCreate,
|
||||
created_by: uuid.UUID
|
||||
) -> SupplierInvoice:
|
||||
"""Create a supplier invoice"""
|
||||
try:
|
||||
logger.info("Creating supplier invoice", tenant_id=tenant_id)
|
||||
|
||||
# Calculate total
|
||||
total_amount = (
|
||||
invoice_data.subtotal +
|
||||
invoice_data.tax_amount +
|
||||
invoice_data.shipping_cost -
|
||||
invoice_data.discount_amount
|
||||
)
|
||||
|
||||
# Get PO for currency
|
||||
po = await self.po_repo.get_po_by_id(invoice_data.purchase_order_id, tenant_id)
|
||||
if not po:
|
||||
raise ValueError("Purchase order not found")
|
||||
|
||||
invoice_create_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'purchase_order_id': invoice_data.purchase_order_id,
|
||||
'supplier_id': invoice_data.supplier_id,
|
||||
'invoice_number': invoice_data.invoice_number,
|
||||
'status': 'received',
|
||||
'invoice_date': invoice_data.invoice_date,
|
||||
'due_date': invoice_data.due_date,
|
||||
'subtotal': invoice_data.subtotal,
|
||||
'tax_amount': invoice_data.tax_amount,
|
||||
'shipping_cost': invoice_data.shipping_cost,
|
||||
'discount_amount': invoice_data.discount_amount,
|
||||
'total_amount': total_amount,
|
||||
'currency': po.currency,
|
||||
'paid_amount': Decimal('0'),
|
||||
'remaining_amount': total_amount,
|
||||
'notes': invoice_data.notes,
|
||||
'payment_reference': invoice_data.payment_reference,
|
||||
'created_by': created_by,
|
||||
'updated_by': created_by,
|
||||
'created_at': datetime.utcnow(),
|
||||
'updated_at': datetime.utcnow(),
|
||||
}
|
||||
|
||||
invoice = await self.invoice_repo.create_invoice(invoice_create_data)
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("Supplier invoice created", invoice_id=invoice.id)
|
||||
return invoice
|
||||
|
||||
except Exception as e:
|
||||
await self.db.rollback()
|
||||
logger.error("Error creating invoice", error=str(e), tenant_id=tenant_id)
|
||||
raise
|
||||
|
||||
# ================================================================
|
||||
# PRIVATE HELPER METHODS
|
||||
# ================================================================
|
||||
|
||||
async def _get_and_validate_supplier(self, tenant_id: uuid.UUID, supplier_id: uuid.UUID) -> Dict[str, Any]:
|
||||
"""Get and validate supplier from Suppliers Service"""
|
||||
try:
|
||||
supplier = await self.suppliers_client.get_supplier(str(tenant_id), str(supplier_id))
|
||||
|
||||
if not supplier:
|
||||
raise ValueError("Supplier not found")
|
||||
|
||||
if supplier.get('status') != 'active':
|
||||
raise ValueError("Cannot create orders for inactive suppliers")
|
||||
|
||||
return supplier
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error validating supplier", error=str(e), supplier_id=supplier_id)
|
||||
raise
|
||||
|
||||
async def _enrich_po_with_supplier(self, tenant_id: uuid.UUID, po: PurchaseOrder) -> None:
|
||||
"""Enrich purchase order with supplier information"""
|
||||
try:
|
||||
supplier = await self.suppliers_client.get_supplier(str(tenant_id), str(po.supplier_id))
|
||||
if supplier:
|
||||
# Set supplier_name as a dynamic attribute on the model instance
|
||||
po.supplier_name = supplier.get('name', 'Unknown Supplier')
|
||||
except Exception as e:
|
||||
logger.warning("Failed to enrich PO with supplier info", error=str(e), po_id=po.id, supplier_id=po.supplier_id)
|
||||
po.supplier_name = None
|
||||
|
||||
def _requires_approval(self, total_amount: Decimal, priority: str) -> bool:
|
||||
"""Determine if PO requires approval"""
|
||||
manager_threshold = Decimal(str(getattr(settings, 'MANAGER_APPROVAL_THRESHOLD', 1000)))
|
||||
return total_amount >= manager_threshold or priority == 'critical'
|
||||
|
||||
def _determine_initial_status(self, total_amount: Decimal, requires_approval: bool) -> str:
|
||||
"""Determine initial PO status"""
|
||||
auto_approve_threshold = Decimal(str(getattr(settings, 'AUTO_APPROVE_THRESHOLD', 100)))
|
||||
|
||||
if requires_approval:
|
||||
return 'pending_approval'
|
||||
elif total_amount <= auto_approve_threshold:
|
||||
return 'approved'
|
||||
else:
|
||||
return 'draft'
|
||||
|
||||
def _is_valid_status_transition(self, from_status: str, to_status: str) -> bool:
|
||||
"""Validate status transition"""
|
||||
valid_transitions = {
|
||||
'draft': ['pending_approval', 'approved', 'cancelled'],
|
||||
'pending_approval': ['approved', 'rejected', 'cancelled'],
|
||||
'approved': ['sent_to_supplier', 'cancelled'],
|
||||
'sent_to_supplier': ['confirmed', 'cancelled'],
|
||||
'confirmed': ['in_production', 'cancelled'],
|
||||
'in_production': ['shipped', 'cancelled'],
|
||||
'shipped': ['delivered', 'cancelled'],
|
||||
'delivered': ['completed'],
|
||||
'rejected': [],
|
||||
'cancelled': [],
|
||||
'completed': []
|
||||
}
|
||||
|
||||
return to_status in valid_transitions.get(from_status, [])
|
||||
376
services/procurement/app/services/recipe_explosion_service.py
Normal file
376
services/procurement/app/services/recipe_explosion_service.py
Normal file
@@ -0,0 +1,376 @@
|
||||
# ================================================================
|
||||
# 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)
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
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='Insufficient data for safety stock calculation'
|
||||
)
|
||||
|
||||
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
|
||||
]
|
||||
}
|
||||
439
services/procurement/app/services/safety_stock_calculator.py
Normal file
439
services/procurement/app/services/safety_stock_calculator.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Safety Stock Calculator
|
||||
|
||||
Calculates dynamic safety stock based on demand variability,
|
||||
lead time, and service level targets.
|
||||
"""
|
||||
|
||||
import math
|
||||
import statistics
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SafetyStockResult:
|
||||
"""Result of safety stock calculation"""
|
||||
safety_stock_quantity: Decimal
|
||||
service_level: float
|
||||
z_score: float
|
||||
demand_std_dev: float
|
||||
lead_time_days: int
|
||||
calculation_method: str
|
||||
confidence: str # 'high', 'medium', 'low'
|
||||
reasoning: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemandHistory:
|
||||
"""Historical demand data for an ingredient"""
|
||||
ingredient_id: str
|
||||
daily_demands: List[float] # Historical daily demands
|
||||
mean_demand: float
|
||||
std_dev: float
|
||||
coefficient_of_variation: float
|
||||
|
||||
|
||||
class SafetyStockCalculator:
|
||||
"""
|
||||
Calculates safety stock using statistical methods.
|
||||
|
||||
Formula: Safety Stock = Z × σ × √L
|
||||
where:
|
||||
- Z = service level z-score (e.g., 1.96 for 97.5%)
|
||||
- σ = demand standard deviation
|
||||
- L = lead time in days
|
||||
|
||||
This accounts for demand variability during lead time.
|
||||
"""
|
||||
|
||||
# Z-scores for common service levels
|
||||
SERVICE_LEVEL_Z_SCORES = {
|
||||
0.50: 0.00, # 50% - no buffer (not recommended)
|
||||
0.80: 0.84, # 80% service level
|
||||
0.85: 1.04, # 85% service level
|
||||
0.90: 1.28, # 90% service level
|
||||
0.95: 1.65, # 95% service level
|
||||
0.975: 1.96, # 97.5% service level
|
||||
0.99: 2.33, # 99% service level
|
||||
0.995: 2.58, # 99.5% service level
|
||||
0.999: 3.09 # 99.9% service level
|
||||
}
|
||||
|
||||
def __init__(self, default_service_level: float = 0.95):
|
||||
"""
|
||||
Initialize safety stock calculator.
|
||||
|
||||
Args:
|
||||
default_service_level: Default target service level (0-1)
|
||||
"""
|
||||
self.default_service_level = default_service_level
|
||||
|
||||
def calculate_safety_stock(
|
||||
self,
|
||||
demand_std_dev: float,
|
||||
lead_time_days: int,
|
||||
service_level: Optional[float] = None
|
||||
) -> SafetyStockResult:
|
||||
"""
|
||||
Calculate safety stock using standard formula.
|
||||
|
||||
Safety Stock = Z × σ × √L
|
||||
|
||||
Args:
|
||||
demand_std_dev: Standard deviation of daily demand
|
||||
lead_time_days: Supplier lead time in days
|
||||
service_level: Target service level (uses default if None)
|
||||
|
||||
Returns:
|
||||
SafetyStockResult with calculation details
|
||||
"""
|
||||
if service_level is None:
|
||||
service_level = self.default_service_level
|
||||
|
||||
# Get z-score for service level
|
||||
z_score = self._get_z_score(service_level)
|
||||
|
||||
# Calculate safety stock
|
||||
if lead_time_days <= 0 or demand_std_dev <= 0:
|
||||
return SafetyStockResult(
|
||||
safety_stock_quantity=Decimal('0'),
|
||||
service_level=service_level,
|
||||
z_score=z_score,
|
||||
demand_std_dev=demand_std_dev,
|
||||
lead_time_days=lead_time_days,
|
||||
calculation_method='zero_due_to_invalid_inputs',
|
||||
confidence='low',
|
||||
reasoning='Lead time or demand std dev is zero or negative'
|
||||
)
|
||||
|
||||
# Safety Stock = Z × σ × √L
|
||||
safety_stock = z_score * demand_std_dev * math.sqrt(lead_time_days)
|
||||
|
||||
# Determine confidence
|
||||
confidence = self._determine_confidence(demand_std_dev, lead_time_days)
|
||||
|
||||
reasoning = (
|
||||
f"Service level {service_level*100:.1f}% (Z={z_score:.2f}) × "
|
||||
f"Demand σ={demand_std_dev:.2f} × √{lead_time_days} days"
|
||||
)
|
||||
|
||||
return SafetyStockResult(
|
||||
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
|
||||
service_level=service_level,
|
||||
z_score=z_score,
|
||||
demand_std_dev=demand_std_dev,
|
||||
lead_time_days=lead_time_days,
|
||||
calculation_method='statistical_z_score',
|
||||
confidence=confidence,
|
||||
reasoning=reasoning
|
||||
)
|
||||
|
||||
def calculate_from_demand_history(
|
||||
self,
|
||||
daily_demands: List[float],
|
||||
lead_time_days: int,
|
||||
service_level: Optional[float] = None
|
||||
) -> SafetyStockResult:
|
||||
"""
|
||||
Calculate safety stock from historical demand data.
|
||||
|
||||
Args:
|
||||
daily_demands: List of historical daily demands
|
||||
lead_time_days: Supplier lead time in days
|
||||
service_level: Target service level
|
||||
|
||||
Returns:
|
||||
SafetyStockResult with calculation details
|
||||
"""
|
||||
if not daily_demands or len(daily_demands) < 2:
|
||||
logger.warning("Insufficient demand history for safety stock calculation")
|
||||
return SafetyStockResult(
|
||||
safety_stock_quantity=Decimal('0'),
|
||||
service_level=service_level or self.default_service_level,
|
||||
z_score=0.0,
|
||||
demand_std_dev=0.0,
|
||||
lead_time_days=lead_time_days,
|
||||
calculation_method='insufficient_data',
|
||||
confidence='low',
|
||||
reasoning='Insufficient historical demand data (need at least 2 data points)'
|
||||
)
|
||||
|
||||
# Calculate standard deviation
|
||||
demand_std_dev = statistics.stdev(daily_demands)
|
||||
|
||||
return self.calculate_safety_stock(
|
||||
demand_std_dev=demand_std_dev,
|
||||
lead_time_days=lead_time_days,
|
||||
service_level=service_level
|
||||
)
|
||||
|
||||
def calculate_with_lead_time_variability(
|
||||
self,
|
||||
demand_mean: float,
|
||||
demand_std_dev: float,
|
||||
lead_time_mean: int,
|
||||
lead_time_std_dev: int,
|
||||
service_level: Optional[float] = None
|
||||
) -> SafetyStockResult:
|
||||
"""
|
||||
Calculate safety stock considering both demand AND lead time variability.
|
||||
|
||||
More accurate formula:
|
||||
SS = Z × √(L_mean × σ_demand² + μ_demand² × σ_lead_time²)
|
||||
|
||||
Args:
|
||||
demand_mean: Mean daily demand
|
||||
demand_std_dev: Standard deviation of daily demand
|
||||
lead_time_mean: Mean lead time in days
|
||||
lead_time_std_dev: Standard deviation of lead time
|
||||
service_level: Target service level
|
||||
|
||||
Returns:
|
||||
SafetyStockResult with calculation details
|
||||
"""
|
||||
if service_level is None:
|
||||
service_level = self.default_service_level
|
||||
|
||||
z_score = self._get_z_score(service_level)
|
||||
|
||||
# Calculate combined variance
|
||||
variance = (
|
||||
lead_time_mean * (demand_std_dev ** 2) +
|
||||
(demand_mean ** 2) * (lead_time_std_dev ** 2)
|
||||
)
|
||||
|
||||
safety_stock = z_score * math.sqrt(variance)
|
||||
|
||||
confidence = 'high' if lead_time_std_dev > 0 else 'medium'
|
||||
|
||||
reasoning = (
|
||||
f"Advanced formula considering both demand variability "
|
||||
f"(σ={demand_std_dev:.2f}) and lead time variability (σ={lead_time_std_dev:.1f} days)"
|
||||
)
|
||||
|
||||
return SafetyStockResult(
|
||||
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
|
||||
service_level=service_level,
|
||||
z_score=z_score,
|
||||
demand_std_dev=demand_std_dev,
|
||||
lead_time_days=lead_time_mean,
|
||||
calculation_method='statistical_with_lead_time_variability',
|
||||
confidence=confidence,
|
||||
reasoning=reasoning
|
||||
)
|
||||
|
||||
def calculate_using_fixed_percentage(
|
||||
self,
|
||||
average_demand: float,
|
||||
lead_time_days: int,
|
||||
percentage: float = 0.20
|
||||
) -> SafetyStockResult:
|
||||
"""
|
||||
Calculate safety stock as percentage of lead time demand.
|
||||
|
||||
Simple method: Safety Stock = % × (Average Daily Demand × Lead Time)
|
||||
|
||||
Args:
|
||||
average_demand: Average daily demand
|
||||
lead_time_days: Supplier lead time in days
|
||||
percentage: Safety stock percentage (default 20%)
|
||||
|
||||
Returns:
|
||||
SafetyStockResult with calculation details
|
||||
"""
|
||||
lead_time_demand = average_demand * lead_time_days
|
||||
safety_stock = lead_time_demand * percentage
|
||||
|
||||
reasoning = f"{percentage*100:.0f}% of lead time demand ({lead_time_demand:.2f})"
|
||||
|
||||
return SafetyStockResult(
|
||||
safety_stock_quantity=Decimal(str(round(safety_stock, 2))),
|
||||
service_level=0.0, # Not based on service level
|
||||
z_score=0.0,
|
||||
demand_std_dev=0.0,
|
||||
lead_time_days=lead_time_days,
|
||||
calculation_method='fixed_percentage',
|
||||
confidence='low',
|
||||
reasoning=reasoning
|
||||
)
|
||||
|
||||
def calculate_batch_safety_stock(
|
||||
self,
|
||||
ingredients_data: List[Dict]
|
||||
) -> Dict[str, SafetyStockResult]:
|
||||
"""
|
||||
Calculate safety stock for multiple ingredients.
|
||||
|
||||
Args:
|
||||
ingredients_data: List of dicts with ingredient data
|
||||
|
||||
Returns:
|
||||
Dictionary mapping ingredient_id to SafetyStockResult
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for data in ingredients_data:
|
||||
ingredient_id = data['ingredient_id']
|
||||
|
||||
if 'daily_demands' in data:
|
||||
# Use historical data
|
||||
result = self.calculate_from_demand_history(
|
||||
daily_demands=data['daily_demands'],
|
||||
lead_time_days=data['lead_time_days'],
|
||||
service_level=data.get('service_level')
|
||||
)
|
||||
elif 'demand_std_dev' in data:
|
||||
# Use provided std dev
|
||||
result = self.calculate_safety_stock(
|
||||
demand_std_dev=data['demand_std_dev'],
|
||||
lead_time_days=data['lead_time_days'],
|
||||
service_level=data.get('service_level')
|
||||
)
|
||||
else:
|
||||
# Fallback to percentage method
|
||||
result = self.calculate_using_fixed_percentage(
|
||||
average_demand=data.get('average_demand', 0),
|
||||
lead_time_days=data['lead_time_days'],
|
||||
percentage=data.get('safety_percentage', 0.20)
|
||||
)
|
||||
|
||||
results[ingredient_id] = result
|
||||
|
||||
logger.info(f"Calculated safety stock for {len(results)} ingredients")
|
||||
|
||||
return results
|
||||
|
||||
def analyze_demand_history(
|
||||
self,
|
||||
daily_demands: List[float]
|
||||
) -> DemandHistory:
|
||||
"""
|
||||
Analyze demand history to extract statistics.
|
||||
|
||||
Args:
|
||||
daily_demands: List of historical daily demands
|
||||
|
||||
Returns:
|
||||
DemandHistory with statistics
|
||||
"""
|
||||
if not daily_demands:
|
||||
return DemandHistory(
|
||||
ingredient_id="unknown",
|
||||
daily_demands=[],
|
||||
mean_demand=0.0,
|
||||
std_dev=0.0,
|
||||
coefficient_of_variation=0.0
|
||||
)
|
||||
|
||||
mean_demand = statistics.mean(daily_demands)
|
||||
std_dev = statistics.stdev(daily_demands) if len(daily_demands) >= 2 else 0.0
|
||||
cv = (std_dev / mean_demand) if mean_demand > 0 else 0.0
|
||||
|
||||
return DemandHistory(
|
||||
ingredient_id="unknown",
|
||||
daily_demands=daily_demands,
|
||||
mean_demand=mean_demand,
|
||||
std_dev=std_dev,
|
||||
coefficient_of_variation=cv
|
||||
)
|
||||
|
||||
def _get_z_score(self, service_level: float) -> float:
|
||||
"""
|
||||
Get z-score for service level.
|
||||
|
||||
Args:
|
||||
service_level: Target service level (0-1)
|
||||
|
||||
Returns:
|
||||
Z-score
|
||||
"""
|
||||
# Find closest service level
|
||||
if service_level in self.SERVICE_LEVEL_Z_SCORES:
|
||||
return self.SERVICE_LEVEL_Z_SCORES[service_level]
|
||||
|
||||
# Interpolate or use closest
|
||||
levels = sorted(self.SERVICE_LEVEL_Z_SCORES.keys())
|
||||
|
||||
for i, level in enumerate(levels):
|
||||
if service_level <= level:
|
||||
return self.SERVICE_LEVEL_Z_SCORES[level]
|
||||
|
||||
# Use highest if beyond range
|
||||
return self.SERVICE_LEVEL_Z_SCORES[levels[-1]]
|
||||
|
||||
def _determine_confidence(
|
||||
self,
|
||||
demand_std_dev: float,
|
||||
lead_time_days: int
|
||||
) -> str:
|
||||
"""
|
||||
Determine confidence level of calculation.
|
||||
|
||||
Args:
|
||||
demand_std_dev: Demand standard deviation
|
||||
lead_time_days: Lead time in days
|
||||
|
||||
Returns:
|
||||
Confidence level
|
||||
"""
|
||||
if demand_std_dev == 0:
|
||||
return 'low' # No variability in data
|
||||
|
||||
if lead_time_days < 3:
|
||||
return 'high' # Short lead time, easier to manage
|
||||
elif lead_time_days < 7:
|
||||
return 'medium'
|
||||
else:
|
||||
return 'medium' # Long lead time, more uncertainty
|
||||
|
||||
def recommend_service_level(
|
||||
self,
|
||||
ingredient_category: str,
|
||||
is_critical: bool = False
|
||||
) -> float:
|
||||
"""
|
||||
Recommend service level based on ingredient characteristics.
|
||||
|
||||
Args:
|
||||
ingredient_category: Category of ingredient
|
||||
is_critical: Whether ingredient is business-critical
|
||||
|
||||
Returns:
|
||||
Recommended service level
|
||||
"""
|
||||
# Critical ingredients: very high service level
|
||||
if is_critical:
|
||||
return 0.99
|
||||
|
||||
# Perishables: moderate service level (to avoid waste)
|
||||
if ingredient_category.lower() in ['dairy', 'meat', 'produce', 'fresh']:
|
||||
return 0.90
|
||||
|
||||
# Standard ingredients: high service level
|
||||
return 0.95
|
||||
|
||||
def export_to_dict(self, result: SafetyStockResult) -> Dict:
|
||||
"""
|
||||
Export result to dictionary for API response.
|
||||
|
||||
Args:
|
||||
result: SafetyStockResult
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
'safety_stock_quantity': float(result.safety_stock_quantity),
|
||||
'service_level': result.service_level,
|
||||
'z_score': result.z_score,
|
||||
'demand_std_dev': result.demand_std_dev,
|
||||
'lead_time_days': result.lead_time_days,
|
||||
'calculation_method': result.calculation_method,
|
||||
'confidence': result.confidence,
|
||||
'reasoning': result.reasoning
|
||||
}
|
||||
444
services/procurement/app/services/shelf_life_manager.py
Normal file
444
services/procurement/app/services/shelf_life_manager.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Shelf Life Manager
|
||||
|
||||
Manages shelf life constraints for perishable ingredients to minimize waste
|
||||
and ensure food safety.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
import statistics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShelfLifeConstraint:
|
||||
"""Shelf life constraints for an ingredient"""
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
shelf_life_days: int
|
||||
is_perishable: bool
|
||||
category: str # 'fresh', 'frozen', 'dry', 'canned'
|
||||
max_order_quantity_days: Optional[int] = None # Max days worth to order at once
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShelfLifeAdjustment:
|
||||
"""Result of shelf life adjustment"""
|
||||
original_quantity: Decimal
|
||||
adjusted_quantity: Decimal
|
||||
adjustment_reason: str
|
||||
waste_risk: str # 'low', 'medium', 'high'
|
||||
recommended_order_date: date
|
||||
use_by_date: date
|
||||
is_constrained: bool
|
||||
|
||||
|
||||
class ShelfLifeManager:
|
||||
"""
|
||||
Manages procurement planning considering shelf life constraints.
|
||||
|
||||
For perishable items:
|
||||
1. Don't order too far in advance (will expire)
|
||||
2. Don't order too much at once (will waste)
|
||||
3. Calculate optimal order timing
|
||||
4. Warn about expiration risks
|
||||
"""
|
||||
|
||||
# Category-specific defaults
|
||||
CATEGORY_DEFAULTS = {
|
||||
'fresh': {
|
||||
'max_days_ahead': 2,
|
||||
'max_order_days_supply': 3,
|
||||
'waste_risk_threshold': 0.80
|
||||
},
|
||||
'dairy': {
|
||||
'max_days_ahead': 3,
|
||||
'max_order_days_supply': 5,
|
||||
'waste_risk_threshold': 0.85
|
||||
},
|
||||
'frozen': {
|
||||
'max_days_ahead': 14,
|
||||
'max_order_days_supply': 30,
|
||||
'waste_risk_threshold': 0.90
|
||||
},
|
||||
'dry': {
|
||||
'max_days_ahead': 90,
|
||||
'max_order_days_supply': 90,
|
||||
'waste_risk_threshold': 0.95
|
||||
},
|
||||
'canned': {
|
||||
'max_days_ahead': 180,
|
||||
'max_order_days_supply': 180,
|
||||
'waste_risk_threshold': 0.95
|
||||
}
|
||||
}
|
||||
|
||||
def __init__(self, waste_risk_threshold: float = 0.85):
|
||||
"""
|
||||
Initialize shelf life manager.
|
||||
|
||||
Args:
|
||||
waste_risk_threshold: % of shelf life before considering waste risk
|
||||
"""
|
||||
self.waste_risk_threshold = waste_risk_threshold
|
||||
|
||||
def adjust_order_quantity_for_shelf_life(
|
||||
self,
|
||||
ingredient_id: str,
|
||||
ingredient_name: str,
|
||||
requested_quantity: Decimal,
|
||||
daily_consumption_rate: float,
|
||||
shelf_life_days: int,
|
||||
category: str = 'dry',
|
||||
is_perishable: bool = True,
|
||||
delivery_date: Optional[date] = None
|
||||
) -> ShelfLifeAdjustment:
|
||||
"""
|
||||
Adjust order quantity to prevent waste due to expiration.
|
||||
|
||||
Args:
|
||||
ingredient_id: Ingredient ID
|
||||
ingredient_name: Ingredient name
|
||||
requested_quantity: Requested order quantity
|
||||
daily_consumption_rate: Average daily usage
|
||||
shelf_life_days: Days until expiration
|
||||
category: Ingredient category
|
||||
is_perishable: Whether item is perishable
|
||||
delivery_date: Expected delivery date
|
||||
|
||||
Returns:
|
||||
ShelfLifeAdjustment with adjusted quantity
|
||||
"""
|
||||
if not is_perishable:
|
||||
# Non-perishable, no adjustment needed
|
||||
return ShelfLifeAdjustment(
|
||||
original_quantity=requested_quantity,
|
||||
adjusted_quantity=requested_quantity,
|
||||
adjustment_reason='Non-perishable item, no shelf life constraint',
|
||||
waste_risk='low',
|
||||
recommended_order_date=delivery_date or date.today(),
|
||||
use_by_date=delivery_date + timedelta(days=365) if delivery_date else date.today() + timedelta(days=365),
|
||||
is_constrained=False
|
||||
)
|
||||
|
||||
if delivery_date is None:
|
||||
delivery_date = date.today()
|
||||
|
||||
# Get category defaults
|
||||
defaults = self.CATEGORY_DEFAULTS.get(
|
||||
category.lower(),
|
||||
self.CATEGORY_DEFAULTS['dry']
|
||||
)
|
||||
|
||||
# Calculate use by date
|
||||
use_by_date = delivery_date + timedelta(days=shelf_life_days)
|
||||
|
||||
# Calculate how many days the requested quantity will last
|
||||
if daily_consumption_rate > 0:
|
||||
days_supply = float(requested_quantity) / daily_consumption_rate
|
||||
else:
|
||||
days_supply = 0
|
||||
|
||||
# Calculate maximum safe quantity (using waste risk threshold)
|
||||
safe_shelf_life_days = int(shelf_life_days * self.waste_risk_threshold)
|
||||
max_safe_quantity = Decimal(str(daily_consumption_rate * safe_shelf_life_days))
|
||||
|
||||
# Check if adjustment needed
|
||||
is_constrained = requested_quantity > max_safe_quantity
|
||||
adjusted_quantity = requested_quantity
|
||||
|
||||
if is_constrained:
|
||||
adjusted_quantity = max_safe_quantity
|
||||
adjustment_reason = (
|
||||
f"Reduced from {requested_quantity} to {adjusted_quantity} to fit within "
|
||||
f"{safe_shelf_life_days}-day safe consumption window (shelf life: {shelf_life_days} days)"
|
||||
)
|
||||
logger.warning(
|
||||
f"{ingredient_name}: Order quantity reduced due to shelf life constraint "
|
||||
f"({requested_quantity} → {adjusted_quantity})"
|
||||
)
|
||||
else:
|
||||
adjustment_reason = "Quantity within safe shelf life window"
|
||||
|
||||
# Calculate waste risk
|
||||
waste_risk = self._calculate_waste_risk(
|
||||
days_supply=days_supply,
|
||||
shelf_life_days=shelf_life_days,
|
||||
threshold=defaults['waste_risk_threshold']
|
||||
)
|
||||
|
||||
return ShelfLifeAdjustment(
|
||||
original_quantity=requested_quantity,
|
||||
adjusted_quantity=adjusted_quantity,
|
||||
adjustment_reason=adjustment_reason,
|
||||
waste_risk=waste_risk,
|
||||
recommended_order_date=delivery_date - timedelta(days=defaults['max_days_ahead']),
|
||||
use_by_date=use_by_date,
|
||||
is_constrained=is_constrained
|
||||
)
|
||||
|
||||
def calculate_optimal_order_date(
|
||||
self,
|
||||
required_by_date: date,
|
||||
shelf_life_days: int,
|
||||
category: str = 'dry',
|
||||
lead_time_days: int = 0
|
||||
) -> Tuple[date, str]:
|
||||
"""
|
||||
Calculate optimal order date considering shelf life.
|
||||
|
||||
Args:
|
||||
required_by_date: When item is needed
|
||||
shelf_life_days: Shelf life in days
|
||||
category: Ingredient category
|
||||
lead_time_days: Supplier lead time
|
||||
|
||||
Returns:
|
||||
Tuple of (optimal_order_date, reasoning)
|
||||
"""
|
||||
defaults = self.CATEGORY_DEFAULTS.get(
|
||||
category.lower(),
|
||||
self.CATEGORY_DEFAULTS['dry']
|
||||
)
|
||||
|
||||
# Calculate delivery date accounting for lead time
|
||||
delivery_date = required_by_date - timedelta(days=lead_time_days)
|
||||
|
||||
# For perishables, don't deliver too far in advance
|
||||
max_advance_days = min(
|
||||
defaults['max_days_ahead'],
|
||||
int(shelf_life_days * 0.3) # Max 30% of shelf life
|
||||
)
|
||||
|
||||
# Optimal delivery: close to required date but not too early
|
||||
optimal_delivery_date = required_by_date - timedelta(days=max_advance_days)
|
||||
|
||||
# Optimal order date
|
||||
optimal_order_date = optimal_delivery_date - timedelta(days=lead_time_days)
|
||||
|
||||
reasoning = (
|
||||
f"Order placed {lead_time_days} days before delivery "
|
||||
f"(arrives {max_advance_days} days before use to maintain freshness)"
|
||||
)
|
||||
|
||||
return optimal_order_date, reasoning
|
||||
|
||||
def validate_order_timing(
|
||||
self,
|
||||
order_date: date,
|
||||
delivery_date: date,
|
||||
required_by_date: date,
|
||||
shelf_life_days: int,
|
||||
ingredient_name: str
|
||||
) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate order timing against shelf life constraints.
|
||||
|
||||
Args:
|
||||
order_date: Planned order date
|
||||
delivery_date: Expected delivery date
|
||||
required_by_date: Date when item is needed
|
||||
shelf_life_days: Shelf life in days
|
||||
ingredient_name: Name of ingredient
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list of warnings)
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
# Check if item will arrive in time
|
||||
if delivery_date > required_by_date:
|
||||
warnings.append(
|
||||
f"Delivery date {delivery_date} is after required date {required_by_date}"
|
||||
)
|
||||
|
||||
# Check if item will expire before use
|
||||
expiry_date = delivery_date + timedelta(days=shelf_life_days)
|
||||
if expiry_date < required_by_date:
|
||||
warnings.append(
|
||||
f"Item will expire on {expiry_date} before required date {required_by_date}"
|
||||
)
|
||||
|
||||
# Check if ordering too far in advance
|
||||
days_in_storage = (required_by_date - delivery_date).days
|
||||
if days_in_storage > shelf_life_days * 0.8:
|
||||
warnings.append(
|
||||
f"Item will be in storage for {days_in_storage} days "
|
||||
f"(80% of {shelf_life_days}-day shelf life)"
|
||||
)
|
||||
|
||||
is_valid = len(warnings) == 0
|
||||
|
||||
if not is_valid:
|
||||
for warning in warnings:
|
||||
logger.warning(f"{ingredient_name}: {warning}")
|
||||
|
||||
return is_valid, warnings
|
||||
|
||||
def calculate_fifo_rotation_schedule(
|
||||
self,
|
||||
current_inventory: List[Dict],
|
||||
new_order_quantity: Decimal,
|
||||
delivery_date: date,
|
||||
daily_consumption: float
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Calculate FIFO (First In First Out) rotation schedule.
|
||||
|
||||
Args:
|
||||
current_inventory: List of existing batches with expiry dates
|
||||
new_order_quantity: New order quantity
|
||||
delivery_date: New order delivery date
|
||||
daily_consumption: Daily consumption rate
|
||||
|
||||
Returns:
|
||||
List of usage schedule
|
||||
"""
|
||||
# Combine current and new inventory
|
||||
all_batches = []
|
||||
|
||||
for batch in current_inventory:
|
||||
all_batches.append({
|
||||
'quantity': batch['quantity'],
|
||||
'expiry_date': batch['expiry_date'],
|
||||
'is_existing': True
|
||||
})
|
||||
|
||||
# Add new order (estimate shelf life from existing batches)
|
||||
if current_inventory:
|
||||
avg_shelf_life_days = statistics.mean([
|
||||
(batch['expiry_date'] - date.today()).days
|
||||
for batch in current_inventory
|
||||
])
|
||||
else:
|
||||
avg_shelf_life_days = 30
|
||||
|
||||
all_batches.append({
|
||||
'quantity': new_order_quantity,
|
||||
'expiry_date': delivery_date + timedelta(days=int(avg_shelf_life_days)),
|
||||
'is_existing': False
|
||||
})
|
||||
|
||||
# Sort by expiry date (FIFO)
|
||||
all_batches.sort(key=lambda x: x['expiry_date'])
|
||||
|
||||
# Create consumption schedule
|
||||
schedule = []
|
||||
current_date = date.today()
|
||||
remaining_consumption = daily_consumption
|
||||
|
||||
for batch in all_batches:
|
||||
days_until_expiry = (batch['expiry_date'] - current_date).days
|
||||
batch_quantity = float(batch['quantity'])
|
||||
|
||||
# Calculate days to consume this batch
|
||||
days_to_consume = min(
|
||||
batch_quantity / daily_consumption,
|
||||
days_until_expiry
|
||||
)
|
||||
|
||||
quantity_consumed = days_to_consume * daily_consumption
|
||||
waste = max(0, batch_quantity - quantity_consumed)
|
||||
|
||||
schedule.append({
|
||||
'start_date': current_date,
|
||||
'end_date': current_date + timedelta(days=int(days_to_consume)),
|
||||
'quantity': batch['quantity'],
|
||||
'quantity_consumed': Decimal(str(quantity_consumed)),
|
||||
'quantity_wasted': Decimal(str(waste)),
|
||||
'expiry_date': batch['expiry_date'],
|
||||
'is_existing': batch['is_existing']
|
||||
})
|
||||
|
||||
current_date += timedelta(days=int(days_to_consume))
|
||||
|
||||
return schedule
|
||||
|
||||
def _calculate_waste_risk(
|
||||
self,
|
||||
days_supply: float,
|
||||
shelf_life_days: int,
|
||||
threshold: float
|
||||
) -> str:
|
||||
"""
|
||||
Calculate waste risk level.
|
||||
|
||||
Args:
|
||||
days_supply: Days of supply ordered
|
||||
shelf_life_days: Shelf life in days
|
||||
threshold: Waste risk threshold
|
||||
|
||||
Returns:
|
||||
Risk level: 'low', 'medium', 'high'
|
||||
"""
|
||||
if days_supply <= shelf_life_days * threshold * 0.5:
|
||||
return 'low'
|
||||
elif days_supply <= shelf_life_days * threshold:
|
||||
return 'medium'
|
||||
else:
|
||||
return 'high'
|
||||
|
||||
def get_expiration_alerts(
|
||||
self,
|
||||
inventory_batches: List[Dict],
|
||||
alert_days_threshold: int = 3
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Get alerts for batches expiring soon.
|
||||
|
||||
Args:
|
||||
inventory_batches: List of batches with expiry dates
|
||||
alert_days_threshold: Days before expiry to alert
|
||||
|
||||
Returns:
|
||||
List of expiration alerts
|
||||
"""
|
||||
alerts = []
|
||||
today = date.today()
|
||||
|
||||
for batch in inventory_batches:
|
||||
expiry_date = batch.get('expiry_date')
|
||||
if not expiry_date:
|
||||
continue
|
||||
|
||||
days_until_expiry = (expiry_date - today).days
|
||||
|
||||
if days_until_expiry <= alert_days_threshold:
|
||||
alerts.append({
|
||||
'ingredient_id': batch.get('ingredient_id'),
|
||||
'ingredient_name': batch.get('ingredient_name'),
|
||||
'quantity': batch.get('quantity'),
|
||||
'expiry_date': expiry_date,
|
||||
'days_until_expiry': days_until_expiry,
|
||||
'severity': 'critical' if days_until_expiry <= 1 else 'high'
|
||||
})
|
||||
|
||||
if alerts:
|
||||
logger.warning(f"Found {len(alerts)} batches expiring within {alert_days_threshold} days")
|
||||
|
||||
return alerts
|
||||
|
||||
def export_to_dict(self, adjustment: ShelfLifeAdjustment) -> Dict:
|
||||
"""
|
||||
Export adjustment to dictionary for API response.
|
||||
|
||||
Args:
|
||||
adjustment: ShelfLifeAdjustment
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
'original_quantity': float(adjustment.original_quantity),
|
||||
'adjusted_quantity': float(adjustment.adjusted_quantity),
|
||||
'adjustment_reason': adjustment.adjustment_reason,
|
||||
'waste_risk': adjustment.waste_risk,
|
||||
'recommended_order_date': adjustment.recommended_order_date.isoformat(),
|
||||
'use_by_date': adjustment.use_by_date.isoformat(),
|
||||
'is_constrained': adjustment.is_constrained
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
# ================================================================
|
||||
# services/procurement/app/services/smart_procurement_calculator.py
|
||||
# ================================================================
|
||||
"""
|
||||
Smart Procurement Calculator
|
||||
Migrated from Orders Service
|
||||
|
||||
Implements multi-constraint procurement quantity optimization combining:
|
||||
- AI demand forecasting
|
||||
- Ingredient reorder rules (reorder_point, reorder_quantity)
|
||||
- Supplier constraints (minimum_order_quantity, minimum_order_amount)
|
||||
- Storage limits (max_stock_level)
|
||||
- Price tier optimization
|
||||
"""
|
||||
|
||||
import math
|
||||
from decimal import Decimal
|
||||
from typing import Dict, Any, List, Tuple, Optional
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SmartProcurementCalculator:
|
||||
"""
|
||||
Smart procurement quantity calculator with multi-tier constraint optimization
|
||||
"""
|
||||
|
||||
def __init__(self, procurement_settings: Dict[str, Any]):
|
||||
"""
|
||||
Initialize calculator with tenant procurement settings
|
||||
|
||||
Args:
|
||||
procurement_settings: Tenant settings dict with flags:
|
||||
- use_reorder_rules: bool
|
||||
- economic_rounding: bool
|
||||
- respect_storage_limits: bool
|
||||
- use_supplier_minimums: bool
|
||||
- optimize_price_tiers: bool
|
||||
"""
|
||||
self.use_reorder_rules = procurement_settings.get('use_reorder_rules', True)
|
||||
self.economic_rounding = procurement_settings.get('economic_rounding', True)
|
||||
self.respect_storage_limits = procurement_settings.get('respect_storage_limits', True)
|
||||
self.use_supplier_minimums = procurement_settings.get('use_supplier_minimums', True)
|
||||
self.optimize_price_tiers = procurement_settings.get('optimize_price_tiers', True)
|
||||
|
||||
def calculate_procurement_quantity(
|
||||
self,
|
||||
ingredient: Dict[str, Any],
|
||||
supplier: Optional[Dict[str, Any]],
|
||||
price_list_entry: Optional[Dict[str, Any]],
|
||||
ai_forecast_quantity: Decimal,
|
||||
current_stock: Decimal,
|
||||
safety_stock_percentage: Decimal = Decimal('20.0')
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate optimal procurement quantity using smart hybrid approach
|
||||
|
||||
Args:
|
||||
ingredient: Ingredient data with reorder_point, reorder_quantity, max_stock_level
|
||||
supplier: Supplier data with minimum_order_amount
|
||||
price_list_entry: Price list with minimum_order_quantity, tier_pricing
|
||||
ai_forecast_quantity: AI-predicted demand quantity
|
||||
current_stock: Current stock level
|
||||
safety_stock_percentage: Safety stock buffer percentage
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- order_quantity: Final calculated quantity to order
|
||||
- calculation_method: Method used (e.g., 'REORDER_POINT_TRIGGERED')
|
||||
- ai_suggested_quantity: Original AI forecast
|
||||
- adjusted_quantity: Final quantity after constraints
|
||||
- adjustment_reason: Human-readable explanation
|
||||
- warnings: List of warnings/notes
|
||||
- supplier_minimum_applied: bool
|
||||
- storage_limit_applied: bool
|
||||
- reorder_rule_applied: bool
|
||||
- price_tier_applied: Dict or None
|
||||
"""
|
||||
warnings = []
|
||||
result = {
|
||||
'ai_suggested_quantity': ai_forecast_quantity,
|
||||
'supplier_minimum_applied': False,
|
||||
'storage_limit_applied': False,
|
||||
'reorder_rule_applied': False,
|
||||
'price_tier_applied': None
|
||||
}
|
||||
|
||||
# Extract ingredient parameters
|
||||
reorder_point = Decimal(str(ingredient.get('reorder_point', 0)))
|
||||
reorder_quantity = Decimal(str(ingredient.get('reorder_quantity', 0)))
|
||||
low_stock_threshold = Decimal(str(ingredient.get('low_stock_threshold', 0)))
|
||||
max_stock_level = Decimal(str(ingredient.get('max_stock_level') or 'Infinity'))
|
||||
|
||||
# Extract supplier/price list parameters
|
||||
supplier_min_qty = Decimal('0')
|
||||
supplier_min_amount = Decimal('0')
|
||||
tier_pricing = []
|
||||
|
||||
if price_list_entry:
|
||||
supplier_min_qty = Decimal(str(price_list_entry.get('minimum_order_quantity', 0)))
|
||||
tier_pricing = price_list_entry.get('tier_pricing') or []
|
||||
|
||||
if supplier:
|
||||
supplier_min_amount = Decimal(str(supplier.get('minimum_order_amount', 0)))
|
||||
|
||||
# Calculate AI-based net requirement with safety stock
|
||||
safety_stock = ai_forecast_quantity * (safety_stock_percentage / Decimal('100'))
|
||||
total_needed = ai_forecast_quantity + safety_stock
|
||||
ai_net_requirement = max(Decimal('0'), total_needed - current_stock)
|
||||
|
||||
# TIER 1: Critical Safety Check (Emergency Override)
|
||||
if self.use_reorder_rules and current_stock <= low_stock_threshold:
|
||||
base_order = max(reorder_quantity, ai_net_requirement)
|
||||
result['calculation_method'] = 'CRITICAL_STOCK_EMERGENCY'
|
||||
result['reorder_rule_applied'] = True
|
||||
warnings.append(f"CRITICAL: Stock ({current_stock}) below threshold ({low_stock_threshold})")
|
||||
order_qty = base_order
|
||||
|
||||
# TIER 2: Reorder Point Triggered
|
||||
elif self.use_reorder_rules and current_stock <= reorder_point:
|
||||
base_order = max(reorder_quantity, ai_net_requirement)
|
||||
result['calculation_method'] = 'REORDER_POINT_TRIGGERED'
|
||||
result['reorder_rule_applied'] = True
|
||||
warnings.append(f"Reorder point triggered: stock ({current_stock}) ≤ reorder point ({reorder_point})")
|
||||
order_qty = base_order
|
||||
|
||||
# TIER 3: Forecast-Driven (Above reorder point, no immediate need)
|
||||
elif ai_net_requirement > 0:
|
||||
order_qty = ai_net_requirement
|
||||
result['calculation_method'] = 'FORECAST_DRIVEN_PROACTIVE'
|
||||
warnings.append(f"AI forecast suggests ordering {ai_net_requirement} units")
|
||||
|
||||
# TIER 4: No Order Needed
|
||||
else:
|
||||
result['order_quantity'] = Decimal('0')
|
||||
result['adjusted_quantity'] = Decimal('0')
|
||||
result['calculation_method'] = 'SUFFICIENT_STOCK'
|
||||
result['adjustment_reason'] = f"Current stock ({current_stock}) is sufficient. No order needed."
|
||||
result['warnings'] = warnings
|
||||
return result
|
||||
|
||||
# Apply Economic Rounding (reorder_quantity multiples)
|
||||
if self.economic_rounding and reorder_quantity > 0:
|
||||
multiples = math.ceil(float(order_qty / reorder_quantity))
|
||||
rounded_qty = Decimal(multiples) * reorder_quantity
|
||||
if rounded_qty > order_qty:
|
||||
warnings.append(f"Rounded to {multiples}× reorder quantity ({reorder_quantity}) = {rounded_qty}")
|
||||
order_qty = rounded_qty
|
||||
|
||||
# Apply Supplier Minimum Quantity Constraint
|
||||
if self.use_supplier_minimums and supplier_min_qty > 0:
|
||||
if order_qty < supplier_min_qty:
|
||||
warnings.append(f"Increased from {order_qty} to supplier minimum ({supplier_min_qty})")
|
||||
order_qty = supplier_min_qty
|
||||
result['supplier_minimum_applied'] = True
|
||||
else:
|
||||
# Round to multiples of minimum_order_quantity (packaging constraint)
|
||||
multiples = math.ceil(float(order_qty / supplier_min_qty))
|
||||
rounded_qty = Decimal(multiples) * supplier_min_qty
|
||||
if rounded_qty > order_qty:
|
||||
warnings.append(f"Rounded to {multiples}× supplier packaging ({supplier_min_qty}) = {rounded_qty}")
|
||||
result['supplier_minimum_applied'] = True
|
||||
order_qty = rounded_qty
|
||||
|
||||
# Apply Price Tier Optimization
|
||||
if self.optimize_price_tiers and tier_pricing and price_list_entry:
|
||||
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
|
||||
tier_result = self._optimize_price_tier(
|
||||
order_qty,
|
||||
unit_price,
|
||||
tier_pricing,
|
||||
current_stock,
|
||||
max_stock_level
|
||||
)
|
||||
|
||||
if tier_result['tier_applied']:
|
||||
order_qty = tier_result['optimized_quantity']
|
||||
result['price_tier_applied'] = tier_result['tier_info']
|
||||
warnings.append(tier_result['message'])
|
||||
|
||||
# Apply Storage Capacity Constraint
|
||||
if self.respect_storage_limits and max_stock_level != Decimal('Infinity'):
|
||||
if (current_stock + order_qty) > max_stock_level:
|
||||
capped_qty = max(Decimal('0'), max_stock_level - current_stock)
|
||||
warnings.append(f"Capped from {order_qty} to {capped_qty} due to storage limit ({max_stock_level})")
|
||||
order_qty = capped_qty
|
||||
result['storage_limit_applied'] = True
|
||||
result['calculation_method'] += '_STORAGE_LIMITED'
|
||||
|
||||
# Check supplier minimum_order_amount (total order value constraint)
|
||||
if self.use_supplier_minimums and supplier_min_amount > 0 and price_list_entry:
|
||||
unit_price = Decimal(str(price_list_entry.get('unit_price', 0)))
|
||||
order_value = order_qty * unit_price
|
||||
|
||||
if order_value < supplier_min_amount:
|
||||
warnings.append(
|
||||
f"⚠️ Order value €{order_value:.2f} < supplier minimum €{supplier_min_amount:.2f}. "
|
||||
"This item needs to be combined with other products in the same PO."
|
||||
)
|
||||
result['calculation_method'] += '_NEEDS_CONSOLIDATION'
|
||||
|
||||
# Build final result
|
||||
result['order_quantity'] = order_qty
|
||||
result['adjusted_quantity'] = order_qty
|
||||
result['adjustment_reason'] = self._build_adjustment_reason(
|
||||
ai_forecast_quantity,
|
||||
ai_net_requirement,
|
||||
order_qty,
|
||||
warnings,
|
||||
result
|
||||
)
|
||||
result['warnings'] = warnings
|
||||
|
||||
return result
|
||||
|
||||
def _optimize_price_tier(
|
||||
self,
|
||||
current_qty: Decimal,
|
||||
base_unit_price: Decimal,
|
||||
tier_pricing: List[Dict[str, Any]],
|
||||
current_stock: Decimal,
|
||||
max_stock_level: Decimal
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Optimize order quantity to capture volume discount tiers if beneficial
|
||||
|
||||
Args:
|
||||
current_qty: Current calculated order quantity
|
||||
base_unit_price: Base unit price without tiers
|
||||
tier_pricing: List of tier dicts with 'quantity' and 'price'
|
||||
current_stock: Current stock level
|
||||
max_stock_level: Maximum storage capacity
|
||||
|
||||
Returns:
|
||||
Dict with tier_applied (bool), optimized_quantity, tier_info, message
|
||||
"""
|
||||
if not tier_pricing:
|
||||
return {'tier_applied': False, 'optimized_quantity': current_qty}
|
||||
|
||||
# Sort tiers by quantity
|
||||
sorted_tiers = sorted(tier_pricing, key=lambda x: x['quantity'])
|
||||
|
||||
best_tier = None
|
||||
best_savings = Decimal('0')
|
||||
|
||||
for tier in sorted_tiers:
|
||||
tier_qty = Decimal(str(tier['quantity']))
|
||||
tier_price = Decimal(str(tier['price']))
|
||||
|
||||
# Skip if tier quantity is below current quantity (already captured)
|
||||
if tier_qty <= current_qty:
|
||||
continue
|
||||
|
||||
# Skip if tier would exceed storage capacity
|
||||
if self.respect_storage_limits and (current_stock + tier_qty) > max_stock_level:
|
||||
continue
|
||||
|
||||
# Skip if tier is more than 50% above current quantity (too much excess)
|
||||
if tier_qty > current_qty * Decimal('1.5'):
|
||||
continue
|
||||
|
||||
# Calculate savings
|
||||
current_cost = current_qty * base_unit_price
|
||||
tier_cost = tier_qty * tier_price
|
||||
savings = current_cost - tier_cost
|
||||
|
||||
if savings > best_savings:
|
||||
best_savings = savings
|
||||
best_tier = {
|
||||
'quantity': tier_qty,
|
||||
'price': tier_price,
|
||||
'savings': savings
|
||||
}
|
||||
|
||||
if best_tier:
|
||||
return {
|
||||
'tier_applied': True,
|
||||
'optimized_quantity': best_tier['quantity'],
|
||||
'tier_info': best_tier,
|
||||
'message': (
|
||||
f"Upgraded to {best_tier['quantity']} units "
|
||||
f"@ €{best_tier['price']}/unit "
|
||||
f"(saves €{best_tier['savings']:.2f})"
|
||||
)
|
||||
}
|
||||
|
||||
return {'tier_applied': False, 'optimized_quantity': current_qty}
|
||||
|
||||
def _build_adjustment_reason(
|
||||
self,
|
||||
ai_forecast: Decimal,
|
||||
ai_net_requirement: Decimal,
|
||||
final_quantity: Decimal,
|
||||
warnings: List[str],
|
||||
result: Dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Build human-readable explanation of quantity adjustments
|
||||
|
||||
Args:
|
||||
ai_forecast: Original AI forecast
|
||||
ai_net_requirement: AI forecast + safety stock - current stock
|
||||
final_quantity: Final order quantity after all adjustments
|
||||
warnings: List of warning messages
|
||||
result: Calculation result dict
|
||||
|
||||
Returns:
|
||||
Human-readable adjustment explanation
|
||||
"""
|
||||
parts = []
|
||||
|
||||
# Start with calculation method
|
||||
method = result.get('calculation_method', 'UNKNOWN')
|
||||
parts.append(f"Method: {method.replace('_', ' ').title()}")
|
||||
|
||||
# AI forecast base
|
||||
parts.append(f"AI Forecast: {ai_forecast} units, Net Requirement: {ai_net_requirement} units")
|
||||
|
||||
# Adjustments applied
|
||||
adjustments = []
|
||||
if result.get('reorder_rule_applied'):
|
||||
adjustments.append("reorder rules")
|
||||
if result.get('supplier_minimum_applied'):
|
||||
adjustments.append("supplier minimums")
|
||||
if result.get('storage_limit_applied'):
|
||||
adjustments.append("storage limits")
|
||||
if result.get('price_tier_applied'):
|
||||
adjustments.append("price tier optimization")
|
||||
|
||||
if adjustments:
|
||||
parts.append(f"Adjustments: {', '.join(adjustments)}")
|
||||
|
||||
# Final quantity
|
||||
parts.append(f"Final Quantity: {final_quantity} units")
|
||||
|
||||
# Key warnings
|
||||
if warnings:
|
||||
key_warnings = [w for w in warnings if '⚠️' in w or 'CRITICAL' in w or 'saves €' in w]
|
||||
if key_warnings:
|
||||
parts.append(f"Notes: {'; '.join(key_warnings)}")
|
||||
|
||||
return " | ".join(parts)
|
||||
538
services/procurement/app/services/supplier_selector.py
Normal file
538
services/procurement/app/services/supplier_selector.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
Supplier Selector
|
||||
|
||||
Intelligently selects suppliers based on multi-criteria optimization including
|
||||
price, lead time, quality, reliability, and risk diversification.
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupplierOption:
|
||||
"""Supplier option for an ingredient"""
|
||||
supplier_id: str
|
||||
supplier_name: str
|
||||
unit_price: Decimal
|
||||
lead_time_days: int
|
||||
min_order_quantity: Optional[Decimal] = None
|
||||
max_capacity: Optional[Decimal] = None
|
||||
quality_score: float = 0.85 # 0-1
|
||||
reliability_score: float = 0.90 # 0-1
|
||||
on_time_delivery_rate: float = 0.95 # 0-1
|
||||
current_allocation_percentage: float = 0.0 # Current % of total orders
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupplierAllocation:
|
||||
"""Allocation of quantity to a supplier"""
|
||||
supplier_id: str
|
||||
supplier_name: str
|
||||
allocated_quantity: Decimal
|
||||
allocation_percentage: float
|
||||
allocation_type: str # 'primary', 'backup', 'diversification'
|
||||
unit_price: Decimal
|
||||
total_cost: Decimal
|
||||
lead_time_days: int
|
||||
supplier_score: float
|
||||
score_breakdown: Dict[str, float]
|
||||
allocation_reason: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupplierSelectionResult:
|
||||
"""Complete supplier selection result"""
|
||||
ingredient_id: str
|
||||
ingredient_name: str
|
||||
required_quantity: Decimal
|
||||
allocations: List[SupplierAllocation]
|
||||
total_cost: Decimal
|
||||
weighted_lead_time: float
|
||||
risk_score: float # Lower is better
|
||||
diversification_applied: bool
|
||||
selection_strategy: str
|
||||
|
||||
|
||||
class SupplierSelector:
|
||||
"""
|
||||
Selects optimal suppliers using multi-criteria decision analysis.
|
||||
|
||||
Scoring Factors:
|
||||
1. Price (lower is better)
|
||||
2. Lead time (shorter is better)
|
||||
3. Quality score (higher is better)
|
||||
4. Reliability (higher is better)
|
||||
5. Diversification (balance across suppliers)
|
||||
|
||||
Strategies:
|
||||
- Single source: Best overall supplier
|
||||
- Dual source: Primary + backup
|
||||
- Multi-source: Split across 2-3 suppliers for large orders
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
price_weight: float = 0.40,
|
||||
lead_time_weight: float = 0.20,
|
||||
quality_weight: float = 0.20,
|
||||
reliability_weight: float = 0.20,
|
||||
diversification_threshold: Decimal = Decimal('1000'),
|
||||
max_single_supplier_percentage: float = 0.70
|
||||
):
|
||||
"""
|
||||
Initialize supplier selector.
|
||||
|
||||
Args:
|
||||
price_weight: Weight for price (0-1)
|
||||
lead_time_weight: Weight for lead time (0-1)
|
||||
quality_weight: Weight for quality (0-1)
|
||||
reliability_weight: Weight for reliability (0-1)
|
||||
diversification_threshold: Quantity above which to diversify
|
||||
max_single_supplier_percentage: Max % to single supplier
|
||||
"""
|
||||
self.price_weight = price_weight
|
||||
self.lead_time_weight = lead_time_weight
|
||||
self.quality_weight = quality_weight
|
||||
self.reliability_weight = reliability_weight
|
||||
self.diversification_threshold = diversification_threshold
|
||||
self.max_single_supplier_percentage = max_single_supplier_percentage
|
||||
|
||||
# Validate weights sum to 1
|
||||
total_weight = (
|
||||
price_weight + lead_time_weight + quality_weight + reliability_weight
|
||||
)
|
||||
if abs(total_weight - 1.0) > 0.01:
|
||||
logger.warning(
|
||||
f"Supplier selection weights don't sum to 1.0 (sum={total_weight}), normalizing"
|
||||
)
|
||||
self.price_weight /= total_weight
|
||||
self.lead_time_weight /= total_weight
|
||||
self.quality_weight /= total_weight
|
||||
self.reliability_weight /= total_weight
|
||||
|
||||
def select_suppliers(
|
||||
self,
|
||||
ingredient_id: str,
|
||||
ingredient_name: str,
|
||||
required_quantity: Decimal,
|
||||
supplier_options: List[SupplierOption]
|
||||
) -> SupplierSelectionResult:
|
||||
"""
|
||||
Select optimal supplier(s) for an ingredient.
|
||||
|
||||
Args:
|
||||
ingredient_id: Ingredient ID
|
||||
ingredient_name: Ingredient name
|
||||
required_quantity: Quantity needed
|
||||
supplier_options: List of available suppliers
|
||||
|
||||
Returns:
|
||||
SupplierSelectionResult with allocations
|
||||
"""
|
||||
if not supplier_options:
|
||||
raise ValueError(f"No supplier options available for {ingredient_name}")
|
||||
|
||||
logger.info(
|
||||
f"Selecting suppliers for {ingredient_name}: "
|
||||
f"{required_quantity} units from {len(supplier_options)} options"
|
||||
)
|
||||
|
||||
# Score all suppliers
|
||||
scored_suppliers = self._score_suppliers(supplier_options)
|
||||
|
||||
# Determine selection strategy
|
||||
strategy = self._determine_strategy(required_quantity, supplier_options)
|
||||
|
||||
# Select suppliers based on strategy
|
||||
if strategy == 'single_source':
|
||||
allocations = self._select_single_source(
|
||||
required_quantity,
|
||||
scored_suppliers
|
||||
)
|
||||
elif strategy == 'dual_source':
|
||||
allocations = self._select_dual_source(
|
||||
required_quantity,
|
||||
scored_suppliers
|
||||
)
|
||||
else: # multi_source
|
||||
allocations = self._select_multi_source(
|
||||
required_quantity,
|
||||
scored_suppliers
|
||||
)
|
||||
|
||||
# Calculate result metrics
|
||||
total_cost = sum(alloc.total_cost for alloc in allocations)
|
||||
weighted_lead_time = sum(
|
||||
alloc.lead_time_days * alloc.allocation_percentage
|
||||
for alloc in allocations
|
||||
)
|
||||
risk_score = self._calculate_risk_score(allocations)
|
||||
diversification_applied = len(allocations) > 1
|
||||
|
||||
result = SupplierSelectionResult(
|
||||
ingredient_id=ingredient_id,
|
||||
ingredient_name=ingredient_name,
|
||||
required_quantity=required_quantity,
|
||||
allocations=allocations,
|
||||
total_cost=total_cost,
|
||||
weighted_lead_time=weighted_lead_time,
|
||||
risk_score=risk_score,
|
||||
diversification_applied=diversification_applied,
|
||||
selection_strategy=strategy
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"{ingredient_name}: Selected {len(allocations)} supplier(s) "
|
||||
f"(strategy={strategy}, total_cost=${total_cost:.2f})"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _score_suppliers(
|
||||
self,
|
||||
suppliers: List[SupplierOption]
|
||||
) -> List[Tuple[SupplierOption, float, Dict[str, float]]]:
|
||||
"""
|
||||
Score all suppliers using weighted criteria.
|
||||
|
||||
Args:
|
||||
suppliers: List of supplier options
|
||||
|
||||
Returns:
|
||||
List of (supplier, score, score_breakdown) tuples
|
||||
"""
|
||||
if not suppliers:
|
||||
return []
|
||||
|
||||
# Normalize factors for comparison
|
||||
prices = [s.unit_price for s in suppliers]
|
||||
lead_times = [s.lead_time_days for s in suppliers]
|
||||
|
||||
min_price = min(prices)
|
||||
max_price = max(prices)
|
||||
min_lead_time = min(lead_times)
|
||||
max_lead_time = max(lead_times)
|
||||
|
||||
scored = []
|
||||
|
||||
for supplier in suppliers:
|
||||
# Price score (normalized, lower is better)
|
||||
if max_price > min_price:
|
||||
price_score = 1.0 - float((supplier.unit_price - min_price) / (max_price - min_price))
|
||||
else:
|
||||
price_score = 1.0
|
||||
|
||||
# Lead time score (normalized, shorter is better)
|
||||
if max_lead_time > min_lead_time:
|
||||
lead_time_score = 1.0 - (supplier.lead_time_days - min_lead_time) / (max_lead_time - min_lead_time)
|
||||
else:
|
||||
lead_time_score = 1.0
|
||||
|
||||
# Quality and reliability scores (already 0-1)
|
||||
quality_score = supplier.quality_score
|
||||
reliability_score = supplier.reliability_score
|
||||
|
||||
# Calculate weighted total score
|
||||
total_score = (
|
||||
self.price_weight * price_score +
|
||||
self.lead_time_weight * lead_time_score +
|
||||
self.quality_weight * quality_score +
|
||||
self.reliability_weight * reliability_score
|
||||
)
|
||||
|
||||
score_breakdown = {
|
||||
'price_score': price_score,
|
||||
'lead_time_score': lead_time_score,
|
||||
'quality_score': quality_score,
|
||||
'reliability_score': reliability_score,
|
||||
'total_score': total_score
|
||||
}
|
||||
|
||||
scored.append((supplier, total_score, score_breakdown))
|
||||
|
||||
# Sort by score (descending)
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
return scored
|
||||
|
||||
def _determine_strategy(
|
||||
self,
|
||||
required_quantity: Decimal,
|
||||
suppliers: List[SupplierOption]
|
||||
) -> str:
|
||||
"""
|
||||
Determine selection strategy based on quantity and options.
|
||||
|
||||
Args:
|
||||
required_quantity: Quantity needed
|
||||
suppliers: Available suppliers
|
||||
|
||||
Returns:
|
||||
Strategy: 'single_source', 'dual_source', or 'multi_source'
|
||||
"""
|
||||
if len(suppliers) == 1:
|
||||
return 'single_source'
|
||||
|
||||
# Large orders should be diversified
|
||||
if required_quantity >= self.diversification_threshold:
|
||||
return 'multi_source' if len(suppliers) >= 3 else 'dual_source'
|
||||
|
||||
# Small orders: single source unless quality/reliability concerns
|
||||
avg_reliability = sum(s.reliability_score for s in suppliers) / len(suppliers)
|
||||
if avg_reliability < 0.85:
|
||||
return 'dual_source' # Use backup for unreliable suppliers
|
||||
|
||||
return 'single_source'
|
||||
|
||||
def _select_single_source(
|
||||
self,
|
||||
required_quantity: Decimal,
|
||||
scored_suppliers: List[Tuple[SupplierOption, float, Dict[str, float]]]
|
||||
) -> List[SupplierAllocation]:
|
||||
"""
|
||||
Select single best supplier.
|
||||
|
||||
Args:
|
||||
required_quantity: Quantity needed
|
||||
scored_suppliers: Scored suppliers
|
||||
|
||||
Returns:
|
||||
List with single allocation
|
||||
"""
|
||||
best_supplier, score, score_breakdown = scored_suppliers[0]
|
||||
|
||||
# Check capacity
|
||||
if best_supplier.max_capacity and required_quantity > best_supplier.max_capacity:
|
||||
logger.warning(
|
||||
f"{best_supplier.supplier_name}: Required quantity {required_quantity} "
|
||||
f"exceeds capacity {best_supplier.max_capacity}, will need to split"
|
||||
)
|
||||
# Fall back to dual source
|
||||
return self._select_dual_source(required_quantity, scored_suppliers)
|
||||
|
||||
allocation = SupplierAllocation(
|
||||
supplier_id=best_supplier.supplier_id,
|
||||
supplier_name=best_supplier.supplier_name,
|
||||
allocated_quantity=required_quantity,
|
||||
allocation_percentage=1.0,
|
||||
allocation_type='primary',
|
||||
unit_price=best_supplier.unit_price,
|
||||
total_cost=best_supplier.unit_price * required_quantity,
|
||||
lead_time_days=best_supplier.lead_time_days,
|
||||
supplier_score=score,
|
||||
score_breakdown=score_breakdown,
|
||||
allocation_reason='Best overall score (single source strategy)'
|
||||
)
|
||||
|
||||
return [allocation]
|
||||
|
||||
def _select_dual_source(
|
||||
self,
|
||||
required_quantity: Decimal,
|
||||
scored_suppliers: List[Tuple[SupplierOption, float, Dict[str, float]]]
|
||||
) -> List[SupplierAllocation]:
|
||||
"""
|
||||
Select primary supplier + backup.
|
||||
|
||||
Args:
|
||||
required_quantity: Quantity needed
|
||||
scored_suppliers: Scored suppliers
|
||||
|
||||
Returns:
|
||||
List with two allocations
|
||||
"""
|
||||
if len(scored_suppliers) < 2:
|
||||
return self._select_single_source(required_quantity, scored_suppliers)
|
||||
|
||||
primary_supplier, primary_score, primary_breakdown = scored_suppliers[0]
|
||||
backup_supplier, backup_score, backup_breakdown = scored_suppliers[1]
|
||||
|
||||
# Primary gets 70%, backup gets 30%
|
||||
primary_percentage = self.max_single_supplier_percentage
|
||||
backup_percentage = 1.0 - primary_percentage
|
||||
|
||||
primary_qty = required_quantity * Decimal(str(primary_percentage))
|
||||
backup_qty = required_quantity * Decimal(str(backup_percentage))
|
||||
|
||||
# Check capacities
|
||||
if primary_supplier.max_capacity and primary_qty > primary_supplier.max_capacity:
|
||||
# Rebalance
|
||||
primary_qty = primary_supplier.max_capacity
|
||||
backup_qty = required_quantity - primary_qty
|
||||
primary_percentage = float(primary_qty / required_quantity)
|
||||
backup_percentage = float(backup_qty / required_quantity)
|
||||
|
||||
allocations = [
|
||||
SupplierAllocation(
|
||||
supplier_id=primary_supplier.supplier_id,
|
||||
supplier_name=primary_supplier.supplier_name,
|
||||
allocated_quantity=primary_qty,
|
||||
allocation_percentage=primary_percentage,
|
||||
allocation_type='primary',
|
||||
unit_price=primary_supplier.unit_price,
|
||||
total_cost=primary_supplier.unit_price * primary_qty,
|
||||
lead_time_days=primary_supplier.lead_time_days,
|
||||
supplier_score=primary_score,
|
||||
score_breakdown=primary_breakdown,
|
||||
allocation_reason=f'Primary supplier ({primary_percentage*100:.0f}% allocation)'
|
||||
),
|
||||
SupplierAllocation(
|
||||
supplier_id=backup_supplier.supplier_id,
|
||||
supplier_name=backup_supplier.supplier_name,
|
||||
allocated_quantity=backup_qty,
|
||||
allocation_percentage=backup_percentage,
|
||||
allocation_type='backup',
|
||||
unit_price=backup_supplier.unit_price,
|
||||
total_cost=backup_supplier.unit_price * backup_qty,
|
||||
lead_time_days=backup_supplier.lead_time_days,
|
||||
supplier_score=backup_score,
|
||||
score_breakdown=backup_breakdown,
|
||||
allocation_reason=f'Backup supplier ({backup_percentage*100:.0f}% allocation for risk mitigation)'
|
||||
)
|
||||
]
|
||||
|
||||
return allocations
|
||||
|
||||
def _select_multi_source(
|
||||
self,
|
||||
required_quantity: Decimal,
|
||||
scored_suppliers: List[Tuple[SupplierOption, float, Dict[str, float]]]
|
||||
) -> List[SupplierAllocation]:
|
||||
"""
|
||||
Split across multiple suppliers for large orders.
|
||||
|
||||
Args:
|
||||
required_quantity: Quantity needed
|
||||
scored_suppliers: Scored suppliers
|
||||
|
||||
Returns:
|
||||
List with multiple allocations
|
||||
"""
|
||||
if len(scored_suppliers) < 3:
|
||||
return self._select_dual_source(required_quantity, scored_suppliers)
|
||||
|
||||
# Use top 3 suppliers
|
||||
top_3 = scored_suppliers[:3]
|
||||
|
||||
# Allocate proportionally to scores
|
||||
total_score = sum(score for _, score, _ in top_3)
|
||||
|
||||
allocations = []
|
||||
remaining_qty = required_quantity
|
||||
|
||||
for i, (supplier, score, score_breakdown) in enumerate(top_3):
|
||||
if i == len(top_3) - 1:
|
||||
# Last supplier gets remainder
|
||||
allocated_qty = remaining_qty
|
||||
else:
|
||||
# Allocate based on score proportion
|
||||
proportion = score / total_score
|
||||
allocated_qty = required_quantity * Decimal(str(proportion))
|
||||
|
||||
# Check capacity
|
||||
if supplier.max_capacity and allocated_qty > supplier.max_capacity:
|
||||
allocated_qty = supplier.max_capacity
|
||||
|
||||
allocation_percentage = float(allocated_qty / required_quantity)
|
||||
|
||||
allocation = SupplierAllocation(
|
||||
supplier_id=supplier.supplier_id,
|
||||
supplier_name=supplier.supplier_name,
|
||||
allocated_quantity=allocated_qty,
|
||||
allocation_percentage=allocation_percentage,
|
||||
allocation_type='diversification',
|
||||
unit_price=supplier.unit_price,
|
||||
total_cost=supplier.unit_price * allocated_qty,
|
||||
lead_time_days=supplier.lead_time_days,
|
||||
supplier_score=score,
|
||||
score_breakdown=score_breakdown,
|
||||
allocation_reason=f'Multi-source diversification ({allocation_percentage*100:.0f}%)'
|
||||
)
|
||||
|
||||
allocations.append(allocation)
|
||||
remaining_qty -= allocated_qty
|
||||
|
||||
if remaining_qty <= 0:
|
||||
break
|
||||
|
||||
return allocations
|
||||
|
||||
def _calculate_risk_score(
|
||||
self,
|
||||
allocations: List[SupplierAllocation]
|
||||
) -> float:
|
||||
"""
|
||||
Calculate overall risk score (lower is better).
|
||||
|
||||
Args:
|
||||
allocations: List of allocations
|
||||
|
||||
Returns:
|
||||
Risk score (0-1)
|
||||
"""
|
||||
if not allocations:
|
||||
return 1.0
|
||||
|
||||
# Single source = higher risk
|
||||
diversification_risk = 1.0 / len(allocations)
|
||||
|
||||
# Concentration risk (how much in single supplier)
|
||||
max_allocation = max(alloc.allocation_percentage for alloc in allocations)
|
||||
concentration_risk = max_allocation
|
||||
|
||||
# Reliability risk (average of supplier reliability)
|
||||
# Note: We don't have reliability in SupplierAllocation, estimate from score
|
||||
avg_supplier_score = sum(alloc.supplier_score for alloc in allocations) / len(allocations)
|
||||
reliability_risk = 1.0 - avg_supplier_score
|
||||
|
||||
# Combined risk (weighted)
|
||||
risk_score = (
|
||||
0.4 * diversification_risk +
|
||||
0.3 * concentration_risk +
|
||||
0.3 * reliability_risk
|
||||
)
|
||||
|
||||
return risk_score
|
||||
|
||||
def export_result_to_dict(self, result: SupplierSelectionResult) -> Dict:
|
||||
"""
|
||||
Export result to dictionary for API response.
|
||||
|
||||
Args:
|
||||
result: Supplier selection result
|
||||
|
||||
Returns:
|
||||
Dictionary representation
|
||||
"""
|
||||
return {
|
||||
'ingredient_id': result.ingredient_id,
|
||||
'ingredient_name': result.ingredient_name,
|
||||
'required_quantity': float(result.required_quantity),
|
||||
'total_cost': float(result.total_cost),
|
||||
'weighted_lead_time': result.weighted_lead_time,
|
||||
'risk_score': result.risk_score,
|
||||
'diversification_applied': result.diversification_applied,
|
||||
'selection_strategy': result.selection_strategy,
|
||||
'allocations': [
|
||||
{
|
||||
'supplier_id': alloc.supplier_id,
|
||||
'supplier_name': alloc.supplier_name,
|
||||
'allocated_quantity': float(alloc.allocated_quantity),
|
||||
'allocation_percentage': alloc.allocation_percentage,
|
||||
'allocation_type': alloc.allocation_type,
|
||||
'unit_price': float(alloc.unit_price),
|
||||
'total_cost': float(alloc.total_cost),
|
||||
'lead_time_days': alloc.lead_time_days,
|
||||
'supplier_score': alloc.supplier_score,
|
||||
'score_breakdown': alloc.score_breakdown,
|
||||
'allocation_reason': alloc.allocation_reason
|
||||
}
|
||||
for alloc in result.allocations
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user