Files
bakery-ia/services/procurement/app/services/procurement_service.py
2025-12-13 23:57:54 +01:00

1056 lines
48 KiB
Python

"""
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 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(
recipes_client=self.recipes_client,
inventory_client=self.inventory_client
)
# Initialize Smart Calculator (keep for backward compatibility)
self.smart_calculator = SmartProcurementCalculator(
procurement_settings={
'use_reorder_rules': True,
'economic_rounding': True,
'respect_storage_limits': True,
'use_supplier_minimums': True,
'optimize_price_tiers': True
}
)
# 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)]
)
async def get_procurement_analytics(self, tenant_id: uuid.UUID, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None):
"""
Get procurement analytics dashboard data with real supplier data and trends
"""
try:
logger.info("Retrieving procurement analytics", tenant_id=tenant_id)
# Set default date range if not provided
if not end_date:
end_date = datetime.now()
if not start_date:
start_date = end_date - timedelta(days=30)
# Get procurement plans summary
plans = await self.plan_repo.get_plans_by_tenant(tenant_id, start_date, end_date)
total_plans = len(plans)
# Calculate summary metrics
total_estimated_cost = sum(float(plan.total_estimated_cost or 0) for plan in plans)
total_approved_cost = sum(float(plan.total_approved_cost or 0) for plan in plans)
cost_variance = total_approved_cost - total_estimated_cost
# Get requirements for performance metrics
requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
# Calculate performance metrics
fulfilled_requirements = [r for r in requirements if r.status == 'received']
on_time_deliveries = [r for r in fulfilled_requirements if r.delivery_status == 'delivered']
fulfillment_rate = len(fulfilled_requirements) / len(requirements) if requirements else 0
on_time_rate = len(on_time_deliveries) / len(fulfilled_requirements) if fulfilled_requirements else 0
# Calculate cost accuracy
cost_accuracy = 0
if requirements:
cost_variance_items = [r for r in requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
if cost_variance_items:
cost_accuracy = 1.0 - (sum(
abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
for r in cost_variance_items
) / len(cost_variance_items))
# ============================================================
# TREND CALCULATIONS (7-day comparison)
# ============================================================
trend_start = end_date - timedelta(days=7)
previous_period_end = trend_start
previous_period_start = previous_period_end - timedelta(days=7)
# Get previous period data
prev_requirements = await self.requirement_repo.get_requirements_by_tenant(
tenant_id, previous_period_start, previous_period_end
)
# Calculate previous period metrics
prev_fulfilled = [r for r in prev_requirements if r.status == 'received']
prev_on_time = [r for r in prev_fulfilled if r.delivery_status == 'delivered']
prev_fulfillment_rate = len(prev_fulfilled) / len(prev_requirements) if prev_requirements else 0
prev_on_time_rate = len(prev_on_time) / len(prev_fulfilled) if prev_fulfilled else 0
prev_cost_accuracy = 0
if prev_requirements:
prev_cost_items = [r for r in prev_requirements if r.estimated_total_cost and r.estimated_total_cost != 0]
if prev_cost_items:
prev_cost_accuracy = 1.0 - (sum(
abs(float(r.estimated_total_cost or 0) - float(r.approved_cost or 0)) / float(r.estimated_total_cost or 1)
for r in prev_cost_items
) / len(prev_cost_items))
# Calculate trend percentages
fulfillment_trend = self._calculate_trend_percentage(fulfillment_rate, prev_fulfillment_rate)
on_time_trend = self._calculate_trend_percentage(on_time_rate, prev_on_time_rate)
cost_variance_trend = self._calculate_trend_percentage(cost_accuracy, prev_cost_accuracy)
# Plan status distribution
status_counts = {}
for plan in plans:
status = plan.status
status_counts[status] = status_counts.get(status, 0) + 1
plan_status_distribution = [
{"status": status, "count": count}
for status, count in status_counts.items()
]
# ============================================================
# CRITICAL REQUIREMENTS with REAL INVENTORY DATA
# ============================================================
try:
inventory_items = await self.inventory_client.get_all_ingredients(str(tenant_id))
inventory_map = {str(item.get('id')): item for item in inventory_items}
low_stock_count = 0
for req in requirements:
ingredient_id = str(req.ingredient_id)
if ingredient_id in inventory_map:
inv_item = inventory_map[ingredient_id]
current_stock = float(inv_item.get('quantity_available', 0))
reorder_point = float(inv_item.get('reorder_point', 0))
if current_stock <= reorder_point:
low_stock_count += 1
except Exception as e:
logger.warning("Failed to get inventory data for critical requirements", error=str(e))
low_stock_count = len([r for r in requirements if r.priority == 'high'])
critical_requirements = {
"low_stock": low_stock_count,
"overdue": len([r for r in requirements if r.status == 'pending' and r.required_by_date < datetime.now().date()]),
"high_priority": len([r for r in requirements if r.priority == 'high'])
}
# Recent plans
recent_plans = []
for plan in sorted(plans, key=lambda x: x.created_at, reverse=True)[:5]:
recent_plans.append({
"id": str(plan.id),
"plan_number": plan.plan_number,
"plan_date": plan.plan_date.isoformat() if plan.plan_date else None,
"status": plan.status,
"total_requirements": plan.total_requirements or 0,
"total_estimated_cost": float(plan.total_estimated_cost or 0),
"created_at": plan.created_at.isoformat() if plan.created_at else None
})
# ============================================================
# SUPPLIER PERFORMANCE with REAL SUPPLIER DATA
# ============================================================
supplier_performance = []
supplier_reqs = {}
for req in requirements:
if req.preferred_supplier_id:
supplier_id = str(req.preferred_supplier_id)
if supplier_id not in supplier_reqs:
supplier_reqs[supplier_id] = []
supplier_reqs[supplier_id].append(req)
# Fetch real supplier data
try:
suppliers_data = await self.suppliers_client.get_all_suppliers(str(tenant_id))
suppliers_map = {str(s.get('id')): s for s in suppliers_data}
for supplier_id, reqs in supplier_reqs.items():
fulfilled = len([r for r in reqs if r.status == 'received'])
on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
# Get real supplier info
supplier_info = suppliers_map.get(supplier_id, {})
supplier_name = supplier_info.get('name', f'Unknown Supplier')
# Use real quality rating from supplier data
quality_score = supplier_info.get('quality_rating', 0)
delivery_rating = supplier_info.get('delivery_rating', 0)
supplier_performance.append({
"id": supplier_id,
"name": supplier_name,
"total_orders": len(reqs),
"fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
"on_time_rate": on_time / fulfilled if fulfilled else 0,
"quality_score": quality_score
})
except Exception as e:
logger.warning("Failed to get supplier data, using fallback", error=str(e))
for supplier_id, reqs in supplier_reqs.items():
fulfilled = len([r for r in reqs if r.status == 'received'])
on_time = len([r for r in reqs if r.delivery_status == 'delivered'])
supplier_performance.append({
"id": supplier_id,
"name": f"Supplier {supplier_id[:8]}...",
"total_orders": len(reqs),
"fulfillment_rate": fulfilled / len(reqs) if reqs else 0,
"on_time_rate": on_time / fulfilled if fulfilled else 0,
"quality_score": 0
})
# Cost by category
cost_by_category = []
category_costs = {}
for req in requirements:
category = req.product_category or "Uncategorized"
category_costs[category] = category_costs.get(category, 0) + float(req.estimated_total_cost or 0)
for category, amount in category_costs.items():
cost_by_category.append({
"name": category,
"amount": amount
})
# Quality metrics
quality_reqs = [r for r in requirements if hasattr(r, 'quality_rating') and r.quality_rating]
avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
high_quality_count = len([r for r in quality_reqs if r.quality_rating >= 4.0])
low_quality_count = len([r for r in quality_reqs if r.quality_rating <= 2.0])
analytics_data = {
"summary": {
"total_plans": total_plans,
"total_estimated_cost": total_estimated_cost,
"total_approved_cost": total_approved_cost,
"cost_variance": cost_variance
},
"performance_metrics": {
"average_fulfillment_rate": fulfillment_rate,
"average_on_time_delivery": on_time_rate,
"cost_accuracy": cost_accuracy,
"supplier_performance": avg_quality if quality_reqs else 0,
"fulfillment_trend": fulfillment_trend,
"on_time_trend": on_time_trend,
"cost_variance_trend": cost_variance_trend
},
"plan_status_distribution": plan_status_distribution,
"critical_requirements": critical_requirements,
"recent_plans": recent_plans,
"supplier_performance": supplier_performance,
"cost_by_category": cost_by_category,
"quality_metrics": {
"avg_score": avg_quality,
"high_quality_count": high_quality_count,
"low_quality_count": low_quality_count
}
}
return analytics_data
except Exception as e:
logger.error("Failed to get procurement analytics", error=str(e), tenant_id=tenant_id)
raise
def _calculate_trend_percentage(self, current_value: float, previous_value: float) -> float:
"""
Calculate percentage change between current and previous values
Returns percentage change (e.g., 0.05 for 5% increase, -0.03 for 3% decrease)
"""
if previous_value == 0:
return 0.0 if current_value == 0 else 1.0
change = ((current_value - previous_value) / previous_value)
return round(change, 4)
async def get_procurement_trends(self, tenant_id: uuid.UUID, days: int = 7):
"""
Get time-series procurement trends for charts (last N days)
Returns daily metrics for performance and quality trends
"""
try:
logger.info("Retrieving procurement trends", tenant_id=tenant_id, days=days)
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Get requirements for the period
requirements = await self.requirement_repo.get_requirements_by_tenant(tenant_id, start_date, end_date)
# Group requirements by day
daily_data = {}
for day_offset in range(days):
day_date = (start_date + timedelta(days=day_offset)).date()
daily_data[day_date] = {
'date': day_date.isoformat(),
'requirements': [],
'fulfillment_rate': 0,
'on_time_rate': 0,
'quality_score': 0
}
# Assign requirements to days based on creation date
for req in requirements:
req_date = req.created_at.date() if req.created_at else None
if req_date and req_date in daily_data:
daily_data[req_date]['requirements'].append(req)
# Calculate daily metrics
performance_trend = []
quality_trend = []
for day_date in sorted(daily_data.keys()):
day_reqs = daily_data[day_date]['requirements']
if day_reqs:
# Calculate fulfillment rate
fulfilled = [r for r in day_reqs if r.status == 'received']
fulfillment_rate = len(fulfilled) / len(day_reqs) if day_reqs else 0
# Calculate on-time rate
on_time = [r for r in fulfilled if r.delivery_status == 'delivered']
on_time_rate = len(on_time) / len(fulfilled) if fulfilled else 0
# Calculate quality score
quality_reqs = [r for r in day_reqs if hasattr(r, 'quality_rating') and r.quality_rating]
avg_quality = sum(r.quality_rating for r in quality_reqs) / len(quality_reqs) if quality_reqs else 0
else:
fulfillment_rate = 0
on_time_rate = 0
avg_quality = 0
performance_trend.append({
'date': day_date.isoformat(),
'fulfillment_rate': round(fulfillment_rate, 4),
'on_time_rate': round(on_time_rate, 4)
})
quality_trend.append({
'date': day_date.isoformat(),
'quality_score': round(avg_quality, 2)
})
return {
'performance_trend': performance_trend,
'quality_trend': quality_trend,
'period_days': days,
'start_date': start_date.date().isoformat(),
'end_date': end_date.date().isoformat()
}
except Exception as e:
logger.error("Failed to get procurement trends", error=str(e), tenant_id=tenant_id)
raise
# ============================================================
# 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 purchase orders from procurement plan requirements
Groups requirements by supplier and creates POs with reasoning data
"""
try:
from shared.schemas.reasoning_types import create_po_reasoning_low_stock, create_po_reasoning_forecast_demand
from collections import defaultdict
# Get plan requirements
requirements = await self.requirement_repo.get_requirements_by_plan(plan_id, tenant_id)
if not requirements:
logger.warning(f"No requirements found for plan {plan_id}")
return {'success': False, 'created_pos': [], 'error': 'No requirements found'}
# Group requirements by supplier
supplier_requirements = defaultdict(list)
for req in requirements:
supplier_id = req.preferred_supplier_id
if supplier_id:
supplier_requirements[supplier_id].append(req)
created_pos = []
# Create a PO for each supplier
for supplier_id, reqs in supplier_requirements.items():
try:
# Get supplier info
supplier = await self._get_supplier_by_id(tenant_id, supplier_id)
supplier_name = supplier.get('name', 'Unknown Supplier') if supplier else 'Unknown Supplier'
# Calculate PO totals
subtotal = sum(
(req.estimated_unit_cost or 0) * (req.net_requirement or 0)
for req in reqs
)
# Determine earliest required delivery date
required_delivery_date = min(req.required_by_date for req in reqs)
# Calculate urgency based on delivery date
days_until_delivery = (required_delivery_date - date.today()).days
# Collect product names for reasoning
product_names = [req.product_name for req in reqs[:3]] # First 3 products
if len(reqs) > 3:
product_names.append(f"and {len(reqs) - 3} more")
# Determine reasoning type based on requirements
# If any requirement has low stock, use low_stock_detection
has_low_stock = any(
(req.current_stock_level or 0) < (req.total_quantity_needed or 0) * 0.3
for req in reqs
)
if has_low_stock:
# Calculate aggregate stock data for reasoning
total_current = sum(req.current_stock_level or 0 for req in reqs)
total_required = sum(req.total_quantity_needed or 0 for req in reqs)
reasoning_data = create_po_reasoning_low_stock(
supplier_name=supplier_name,
product_names=product_names,
current_stock=float(total_current),
required_stock=float(total_required),
days_until_stockout=days_until_delivery,
threshold_percentage=20,
affected_products=[req.product_name for req in reqs]
)
else:
# Use forecast-based reasoning
reasoning_data = create_po_reasoning_forecast_demand(
supplier_name=supplier_name,
product_names=product_names,
forecast_period_days=7,
total_demand=float(sum(req.order_demand or 0 for req in reqs))
)
# Generate PO number
po_number = await self._generate_po_number()
# Determine if needs approval (based on amount or config)
requires_approval = subtotal > 500 or not auto_approve # Example threshold
# Create PO
po_data = {
'tenant_id': tenant_id,
'supplier_id': supplier_id,
'procurement_plan_id': plan_id,
'po_number': po_number,
'status': 'approved' if auto_approve and not requires_approval else 'pending_approval',
'priority': 'high' if days_until_delivery <= 2 else 'normal',
'order_date': datetime.now(timezone.utc),
'required_delivery_date': datetime.combine(required_delivery_date, datetime.min.time()),
'subtotal': Decimal(str(subtotal)),
'total_amount': Decimal(str(subtotal)), # Simplified, no tax/shipping
'currency': 'EUR',
'requires_approval': requires_approval,
'reasoning_data': reasoning_data, # NEW: Structured reasoning
'created_at': datetime.now(timezone.utc),
'updated_at': datetime.now(timezone.utc),
'created_by': uuid.uuid4(), # System user
'updated_by': uuid.uuid4()
}
po = await self.po_repo.create_purchase_order(po_data)
# Create PO items
po_items = []
for req in reqs:
item_data = {
'tenant_id': tenant_id,
'purchase_order_id': po.id,
'procurement_requirement_id': req.id,
'inventory_product_id': req.product_id,
'product_name': req.product_name,
'ordered_quantity': req.net_requirement,
'unit_of_measure': req.unit_of_measure,
'unit_price': req.estimated_unit_cost or Decimal('0'),
'line_total': (req.estimated_unit_cost or Decimal('0')) * (req.net_requirement or Decimal('0')),
'remaining_quantity': req.net_requirement,
'created_at': datetime.now(timezone.utc),
'updated_at': datetime.now(timezone.utc)
}
item = await self.po_item_repo.create_po_item(item_data)
po_items.append(item)
created_pos.append({
'id': str(po.id),
'po_number': po.po_number,
'supplier_id': str(supplier_id),
'supplier_name': supplier_name,
'total_amount': float(po.total_amount),
'status': po.status.value if hasattr(po.status, 'value') else po.status,
'items_count': len(po_items)
})
logger.info(f"Created PO {po.po_number} for supplier {supplier_name} with {len(po_items)} items")
except Exception as e:
logger.error(f"Error creating PO for supplier {supplier_id}: {e}", exc_info=True)
continue
return {
'success': True,
'created_pos': created_pos,
'count': len(created_pos)
}
except Exception as e:
logger.error(f"Error in _create_purchase_orders_from_plan: {e}", exc_info=True)
return {'success': False, 'created_pos': [], 'error': str(e)}
async def _generate_po_number(self):
"""Generate unique PO number"""
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
return f"PO-{timestamp}-{uuid.uuid4().hex[:6].upper()}"
async def _get_supplier_by_id(self, tenant_id, supplier_id):
"""Get supplier details by ID"""
try:
return await self.suppliers_client.get_supplier_by_id(str(tenant_id), str(supplier_id))
except Exception as e:
logger.warning(f"Failed to get supplier {supplier_id}: {e}")
return None
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}")