1056 lines
48 KiB
Python
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}")
|