Improve the frontend 3

This commit is contained in:
Urtzi Alfaro
2025-10-30 21:08:07 +01:00
parent 36217a2729
commit 63f5c6d512
184 changed files with 21512 additions and 7442 deletions

View 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",
]

View 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
]
}

View 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

View 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
}

View 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}")

View 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, [])

View 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)
}

View File

@@ -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
]
}

View 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
}

View 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
}

View File

@@ -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)

View 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
]
}