Improve AI logic
This commit is contained in:
532
services/procurement/app/api/ml_insights.py
Normal file
532
services/procurement/app/api/ml_insights.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
ML Insights API Endpoints for Procurement Service
|
||||
|
||||
Provides endpoints to trigger ML insight generation for:
|
||||
- Supplier performance analysis
|
||||
- Price forecasting and timing recommendations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
import structlog
|
||||
import pandas as pd
|
||||
|
||||
from app.core.database import get_db
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/tenants/{tenant_id}/procurement/ml/insights",
|
||||
tags=["ML Insights"]
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS - SUPPLIER ANALYSIS
|
||||
# ================================================================
|
||||
|
||||
class SupplierAnalysisRequest(BaseModel):
|
||||
"""Request schema for supplier performance analysis"""
|
||||
supplier_ids: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Specific supplier IDs to analyze. If None, analyzes all suppliers"
|
||||
)
|
||||
lookback_days: int = Field(
|
||||
180,
|
||||
description="Days of historical orders to analyze",
|
||||
ge=30,
|
||||
le=730
|
||||
)
|
||||
min_orders: int = Field(
|
||||
10,
|
||||
description="Minimum orders required for analysis",
|
||||
ge=5,
|
||||
le=100
|
||||
)
|
||||
|
||||
|
||||
class SupplierAnalysisResponse(BaseModel):
|
||||
"""Response schema for supplier performance analysis"""
|
||||
success: bool
|
||||
message: str
|
||||
tenant_id: str
|
||||
suppliers_analyzed: int
|
||||
total_insights_generated: int
|
||||
total_insights_posted: int
|
||||
high_risk_suppliers: int
|
||||
insights_by_supplier: dict
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# ================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS - PRICE FORECASTING
|
||||
# ================================================================
|
||||
|
||||
class PriceForecastRequest(BaseModel):
|
||||
"""Request schema for price forecasting"""
|
||||
ingredient_ids: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Specific ingredient IDs to forecast. If None, forecasts all ingredients"
|
||||
)
|
||||
lookback_days: int = Field(
|
||||
180,
|
||||
description="Days of historical price data to analyze",
|
||||
ge=90,
|
||||
le=730
|
||||
)
|
||||
forecast_horizon_days: int = Field(
|
||||
30,
|
||||
description="Days to forecast ahead",
|
||||
ge=7,
|
||||
le=90
|
||||
)
|
||||
|
||||
|
||||
class PriceForecastResponse(BaseModel):
|
||||
"""Response schema for price forecasting"""
|
||||
success: bool
|
||||
message: str
|
||||
tenant_id: str
|
||||
ingredients_forecasted: int
|
||||
total_insights_generated: int
|
||||
total_insights_posted: int
|
||||
buy_now_recommendations: int
|
||||
bulk_opportunities: int
|
||||
insights_by_ingredient: dict
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# ================================================================
|
||||
# API ENDPOINTS - SUPPLIER ANALYSIS
|
||||
# ================================================================
|
||||
|
||||
@router.post("/analyze-suppliers", response_model=SupplierAnalysisResponse)
|
||||
async def trigger_supplier_analysis(
|
||||
tenant_id: str,
|
||||
request_data: SupplierAnalysisRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Trigger supplier performance analysis.
|
||||
|
||||
This endpoint:
|
||||
1. Fetches historical purchase order data for specified suppliers
|
||||
2. Runs the SupplierInsightsOrchestrator to analyze reliability
|
||||
3. Generates insights about supplier performance and risk
|
||||
4. Posts insights to AI Insights Service
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
request_data: Analysis parameters
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
SupplierAnalysisResponse with analysis results
|
||||
"""
|
||||
logger.info(
|
||||
"ML insights supplier analysis requested",
|
||||
tenant_id=tenant_id,
|
||||
supplier_ids=request_data.supplier_ids,
|
||||
lookback_days=request_data.lookback_days
|
||||
)
|
||||
|
||||
try:
|
||||
# Import ML orchestrator and clients
|
||||
from app.ml.supplier_insights_orchestrator import SupplierInsightsOrchestrator
|
||||
from app.models.purchase_order import PurchaseOrder
|
||||
from shared.clients.suppliers_client import SuppliersServiceClient
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select
|
||||
|
||||
# Initialize orchestrator and clients
|
||||
orchestrator = SupplierInsightsOrchestrator()
|
||||
suppliers_client = SuppliersServiceClient(settings)
|
||||
|
||||
# Get suppliers to analyze from suppliers service via API
|
||||
if request_data.supplier_ids:
|
||||
# Fetch specific suppliers
|
||||
suppliers = []
|
||||
for supplier_id in request_data.supplier_ids:
|
||||
supplier = await suppliers_client.get_supplier_by_id(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id
|
||||
)
|
||||
if supplier:
|
||||
suppliers.append(supplier)
|
||||
else:
|
||||
# Fetch all active suppliers (limit to 10)
|
||||
all_suppliers = await suppliers_client.get_all_suppliers(
|
||||
tenant_id=tenant_id,
|
||||
is_active=True
|
||||
)
|
||||
suppliers = (all_suppliers or [])[:10] # Limit to prevent timeout
|
||||
|
||||
if not suppliers:
|
||||
return SupplierAnalysisResponse(
|
||||
success=False,
|
||||
message="No suppliers found for analysis",
|
||||
tenant_id=tenant_id,
|
||||
suppliers_analyzed=0,
|
||||
total_insights_generated=0,
|
||||
total_insights_posted=0,
|
||||
high_risk_suppliers=0,
|
||||
insights_by_supplier={},
|
||||
errors=["No suppliers found"]
|
||||
)
|
||||
|
||||
# Calculate date range for order history
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=request_data.lookback_days)
|
||||
|
||||
# Process each supplier
|
||||
total_insights_generated = 0
|
||||
total_insights_posted = 0
|
||||
high_risk_suppliers = 0
|
||||
insights_by_supplier = {}
|
||||
errors = []
|
||||
|
||||
for supplier in suppliers:
|
||||
try:
|
||||
supplier_id = str(supplier['id'])
|
||||
supplier_name = supplier.get('name', 'Unknown')
|
||||
logger.info(f"Analyzing supplier {supplier_name} ({supplier_id})")
|
||||
|
||||
# Get purchase orders for this supplier from local database
|
||||
po_query = select(PurchaseOrder).where(
|
||||
PurchaseOrder.tenant_id == UUID(tenant_id),
|
||||
PurchaseOrder.supplier_id == UUID(supplier_id),
|
||||
PurchaseOrder.order_date >= start_date,
|
||||
PurchaseOrder.order_date <= end_date
|
||||
)
|
||||
|
||||
po_result = await db.execute(po_query)
|
||||
purchase_orders = po_result.scalars().all()
|
||||
|
||||
if len(purchase_orders) < request_data.min_orders:
|
||||
logger.warning(
|
||||
f"Insufficient orders for supplier {supplier_id}: "
|
||||
f"{len(purchase_orders)} < {request_data.min_orders} required"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create order history DataFrame
|
||||
order_data = []
|
||||
for po in purchase_orders:
|
||||
# Calculate delivery performance
|
||||
if po.delivery_date and po.expected_delivery_date:
|
||||
days_late = (po.delivery_date - po.expected_delivery_date).days
|
||||
on_time = days_late <= 0
|
||||
else:
|
||||
days_late = 0
|
||||
on_time = True
|
||||
|
||||
# Calculate quality score (based on status)
|
||||
quality_score = 100 if po.status == 'completed' else 80
|
||||
|
||||
order_data.append({
|
||||
'order_date': po.order_date,
|
||||
'expected_delivery_date': po.expected_delivery_date,
|
||||
'delivery_date': po.delivery_date,
|
||||
'days_late': days_late,
|
||||
'on_time': on_time,
|
||||
'quality_score': quality_score,
|
||||
'total_amount': float(po.total_amount) if po.total_amount else 0
|
||||
})
|
||||
|
||||
order_history = pd.DataFrame(order_data)
|
||||
|
||||
# Run supplier analysis
|
||||
results = await orchestrator.analyze_and_post_supplier_insights(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id,
|
||||
order_history=order_history,
|
||||
min_orders=request_data.min_orders
|
||||
)
|
||||
|
||||
# Track results
|
||||
total_insights_generated += results['insights_generated']
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
reliability_score = results.get('reliability_score', 100)
|
||||
if reliability_score < 70:
|
||||
high_risk_suppliers += 1
|
||||
|
||||
insights_by_supplier[supplier_id] = {
|
||||
'supplier_name': supplier_name,
|
||||
'insights_posted': results['insights_posted'],
|
||||
'reliability_score': reliability_score,
|
||||
'orders_analyzed': results['orders_analyzed']
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Supplier {supplier_id} analysis complete",
|
||||
insights_posted=results['insights_posted'],
|
||||
reliability_score=reliability_score
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error analyzing supplier {supplier_id}: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Close orchestrator
|
||||
await orchestrator.close()
|
||||
|
||||
# Build response
|
||||
response = SupplierAnalysisResponse(
|
||||
success=total_insights_posted > 0,
|
||||
message=f"Successfully analyzed {len(insights_by_supplier)} suppliers, generated {total_insights_posted} insights",
|
||||
tenant_id=tenant_id,
|
||||
suppliers_analyzed=len(insights_by_supplier),
|
||||
total_insights_generated=total_insights_generated,
|
||||
total_insights_posted=total_insights_posted,
|
||||
high_risk_suppliers=high_risk_suppliers,
|
||||
insights_by_supplier=insights_by_supplier,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ML insights supplier analysis complete",
|
||||
tenant_id=tenant_id,
|
||||
total_insights=total_insights_posted,
|
||||
high_risk_suppliers=high_risk_suppliers
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"ML insights supplier analysis failed",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Supplier analysis failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# API ENDPOINTS - PRICE FORECASTING
|
||||
# ================================================================
|
||||
|
||||
@router.post("/forecast-prices", response_model=PriceForecastResponse)
|
||||
async def trigger_price_forecasting(
|
||||
tenant_id: str,
|
||||
request_data: PriceForecastRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Trigger price forecasting for procurement ingredients.
|
||||
|
||||
This endpoint:
|
||||
1. Fetches historical price data for specified ingredients
|
||||
2. Runs the PriceInsightsOrchestrator to forecast future prices
|
||||
3. Generates insights about optimal purchase timing
|
||||
4. Posts insights to AI Insights Service
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
request_data: Forecasting parameters
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
PriceForecastResponse with forecasting results
|
||||
"""
|
||||
logger.info(
|
||||
"ML insights price forecasting requested",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_ids=request_data.ingredient_ids,
|
||||
lookback_days=request_data.lookback_days
|
||||
)
|
||||
|
||||
try:
|
||||
# Import ML orchestrator and clients
|
||||
from app.ml.price_insights_orchestrator import PriceInsightsOrchestrator
|
||||
from shared.clients.inventory_client import InventoryServiceClient
|
||||
from app.models.purchase_order import PurchaseOrderItem
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select
|
||||
|
||||
# Initialize orchestrator and inventory client
|
||||
orchestrator = PriceInsightsOrchestrator()
|
||||
inventory_client = InventoryServiceClient(settings)
|
||||
|
||||
# Get ingredients to forecast from inventory service via API
|
||||
if request_data.ingredient_ids:
|
||||
# Fetch specific ingredients
|
||||
ingredients = []
|
||||
for ingredient_id in request_data.ingredient_ids:
|
||||
ingredient = await inventory_client.get_ingredient_by_id(
|
||||
ingredient_id=ingredient_id,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
if ingredient:
|
||||
ingredients.append(ingredient)
|
||||
else:
|
||||
# Fetch all ingredients for tenant (limit to 10)
|
||||
all_ingredients = await inventory_client.get_all_ingredients(tenant_id=tenant_id)
|
||||
ingredients = all_ingredients[:10] if all_ingredients else [] # Limit to prevent timeout
|
||||
|
||||
if not ingredients:
|
||||
return PriceForecastResponse(
|
||||
success=False,
|
||||
message="No ingredients found for forecasting",
|
||||
tenant_id=tenant_id,
|
||||
ingredients_forecasted=0,
|
||||
total_insights_generated=0,
|
||||
total_insights_posted=0,
|
||||
buy_now_recommendations=0,
|
||||
bulk_opportunities=0,
|
||||
insights_by_ingredient={},
|
||||
errors=["No ingredients found"]
|
||||
)
|
||||
|
||||
# Calculate date range for price history
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=request_data.lookback_days)
|
||||
|
||||
# Process each ingredient
|
||||
total_insights_generated = 0
|
||||
total_insights_posted = 0
|
||||
buy_now_recommendations = 0
|
||||
bulk_opportunities = 0
|
||||
insights_by_ingredient = {}
|
||||
errors = []
|
||||
|
||||
for ingredient in ingredients:
|
||||
try:
|
||||
ingredient_id = str(ingredient['id'])
|
||||
ingredient_name = ingredient.get('name', 'Unknown Ingredient')
|
||||
logger.info(f"Forecasting prices for {ingredient_name} ({ingredient_id})")
|
||||
|
||||
# Get price history from purchase order items
|
||||
poi_query = select(PurchaseOrderItem).where(
|
||||
PurchaseOrderItem.ingredient_id == UUID(ingredient_id)
|
||||
).join(
|
||||
PurchaseOrderItem.purchase_order
|
||||
).where(
|
||||
PurchaseOrderItem.purchase_order.has(
|
||||
tenant_id=UUID(tenant_id)
|
||||
)
|
||||
)
|
||||
|
||||
poi_result = await db.execute(poi_query)
|
||||
purchase_items = poi_result.scalars().all()
|
||||
|
||||
if len(purchase_items) < 30:
|
||||
logger.warning(
|
||||
f"Insufficient price history for ingredient {ingredient_id}: "
|
||||
f"{len(purchase_items)} items"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create price history DataFrame
|
||||
price_data = []
|
||||
for item in purchase_items:
|
||||
if item.unit_price and item.quantity:
|
||||
price_data.append({
|
||||
'date': item.purchase_order.order_date,
|
||||
'price': float(item.unit_price),
|
||||
'quantity': float(item.quantity),
|
||||
'supplier_id': str(item.purchase_order.supplier_id)
|
||||
})
|
||||
|
||||
price_history = pd.DataFrame(price_data)
|
||||
price_history = price_history.sort_values('date')
|
||||
|
||||
# Run price forecasting
|
||||
results = await orchestrator.forecast_and_post_insights(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
price_history=price_history,
|
||||
forecast_horizon_days=request_data.forecast_horizon_days,
|
||||
min_history_days=request_data.lookback_days
|
||||
)
|
||||
|
||||
# Track results
|
||||
total_insights_generated += results['insights_generated']
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
recommendation = results.get('recommendation', {})
|
||||
if recommendation.get('action') == 'buy_now':
|
||||
buy_now_recommendations += 1
|
||||
|
||||
bulk_opp = results.get('bulk_opportunity', {})
|
||||
if bulk_opp.get('has_bulk_opportunity'):
|
||||
bulk_opportunities += 1
|
||||
|
||||
insights_by_ingredient[ingredient_id] = {
|
||||
'ingredient_name': ingredient_name,
|
||||
'insights_posted': results['insights_posted'],
|
||||
'recommendation': recommendation.get('action'),
|
||||
'has_bulk_opportunity': bulk_opp.get('has_bulk_opportunity', False)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Ingredient {ingredient_id} forecasting complete",
|
||||
insights_posted=results['insights_posted'],
|
||||
recommendation=recommendation.get('action')
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error forecasting ingredient {ingredient_id}: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Close orchestrator
|
||||
await orchestrator.close()
|
||||
|
||||
# Build response
|
||||
response = PriceForecastResponse(
|
||||
success=total_insights_posted > 0,
|
||||
message=f"Successfully forecasted {len(insights_by_ingredient)} ingredients, generated {total_insights_posted} insights",
|
||||
tenant_id=tenant_id,
|
||||
ingredients_forecasted=len(insights_by_ingredient),
|
||||
total_insights_generated=total_insights_generated,
|
||||
total_insights_posted=total_insights_posted,
|
||||
buy_now_recommendations=buy_now_recommendations,
|
||||
bulk_opportunities=bulk_opportunities,
|
||||
insights_by_ingredient=insights_by_ingredient,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ML insights price forecasting complete",
|
||||
tenant_id=tenant_id,
|
||||
total_insights=total_insights_posted,
|
||||
buy_now_recommendations=buy_now_recommendations,
|
||||
bulk_opportunities=bulk_opportunities
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"ML insights price forecasting failed",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Price forecasting failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def ml_insights_health():
|
||||
"""Health check for ML insights endpoints"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "procurement-ml-insights",
|
||||
"endpoints": [
|
||||
"POST /ml/insights/analyze-suppliers",
|
||||
"POST /ml/insights/forecast-prices"
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ Procurement Plans API - Endpoints for procurement planning
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -22,11 +22,14 @@ from app.schemas.procurement_schemas import (
|
||||
AutoGenerateProcurementResponse,
|
||||
PaginatedProcurementPlans,
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/procurement", tags=["Procurement Plans"])
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(tags=["procurement-plans"])
|
||||
|
||||
|
||||
def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
|
||||
@@ -38,10 +41,13 @@ def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementSe
|
||||
# ORCHESTRATOR ENTRY POINT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/auto-generate", response_model=AutoGenerateProcurementResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("auto-generate"),
|
||||
response_model=AutoGenerateProcurementResponse
|
||||
)
|
||||
async def auto_generate_procurement(
|
||||
tenant_id: str,
|
||||
request_data: AutoGenerateProcurementRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -82,10 +88,13 @@ async def auto_generate_procurement(
|
||||
# MANUAL PROCUREMENT PLAN GENERATION
|
||||
# ================================================================
|
||||
|
||||
@router.post("/plans/generate", response_model=GeneratePlanResponse)
|
||||
@router.post(
|
||||
route_builder.build_base_route("plans"),
|
||||
response_model=GeneratePlanResponse
|
||||
)
|
||||
async def generate_procurement_plan(
|
||||
tenant_id: str,
|
||||
request_data: GeneratePlanRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""
|
||||
@@ -122,9 +131,12 @@ async def generate_procurement_plan(
|
||||
# PROCUREMENT PLAN CRUD
|
||||
# ================================================================
|
||||
|
||||
@router.get("/plans/current", response_model=Optional[ProcurementPlanResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("plans/current"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
async def get_current_plan(
|
||||
tenant_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get the current day's procurement plan"""
|
||||
@@ -137,10 +149,13 @@ async def get_current_plan(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}", response_model=ProcurementPlanResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("plans", "plan_id"),
|
||||
response_model=ProcurementPlanResponse
|
||||
)
|
||||
async def get_plan_by_id(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement plan by ID"""
|
||||
@@ -159,10 +174,13 @@ async def get_plan_by_id(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans/date/{plan_date}", response_model=Optional[ProcurementPlanResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("plans/date/{plan_date}"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
async def get_plan_by_date(
|
||||
tenant_id: str,
|
||||
plan_date: date,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement plan for a specific date"""
|
||||
@@ -175,9 +193,12 @@ async def get_plan_by_date(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans", response_model=PaginatedProcurementPlans)
|
||||
@router.get(
|
||||
route_builder.build_base_route("plans"),
|
||||
response_model=PaginatedProcurementPlans
|
||||
)
|
||||
async def list_procurement_plans(
|
||||
tenant_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
@@ -206,11 +227,13 @@ async def list_procurement_plans(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/plans/{plan_id}/status")
|
||||
@router.patch(
|
||||
route_builder.build_resource_action_route("plans", "plan_id", "status")
|
||||
)
|
||||
async def update_plan_status(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
notes: Optional[str] = None,
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
@@ -235,11 +258,13 @@ async def update_plan_status(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/create-purchase-orders")
|
||||
@router.post(
|
||||
route_builder.build_resource_action_route("plans", "plan_id", "create-purchase-orders")
|
||||
)
|
||||
async def create_purchase_orders_from_plan(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""
|
||||
@@ -279,10 +304,12 @@ async def create_purchase_orders_from_plan(
|
||||
# TESTING AND UTILITIES
|
||||
# ================================================================
|
||||
|
||||
@router.get("/plans/{plan_id}/requirements")
|
||||
@router.get(
|
||||
route_builder.build_resource_action_route("plans", "plan_id", "requirements")
|
||||
)
|
||||
async def get_plan_requirements(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
|
||||
@@ -7,7 +7,7 @@ Purchase Orders API - Endpoints for purchase order management
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -24,11 +24,14 @@ from app.schemas.purchase_order_schemas import (
|
||||
SupplierInvoiceCreate,
|
||||
SupplierInvoiceResponse,
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/purchase-orders", tags=["Purchase Orders"])
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(tags=["purchase-orders"])
|
||||
|
||||
|
||||
def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService:
|
||||
@@ -40,10 +43,14 @@ def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService:
|
||||
# PURCHASE ORDER CRUD
|
||||
# ================================================================
|
||||
|
||||
@router.post("", response_model=PurchaseOrderResponse, status_code=201)
|
||||
@router.post(
|
||||
route_builder.build_base_route("purchase-orders"),
|
||||
response_model=PurchaseOrderResponse,
|
||||
status_code=201
|
||||
)
|
||||
async def create_purchase_order(
|
||||
tenant_id: str,
|
||||
po_data: PurchaseOrderCreate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -76,10 +83,13 @@ async def create_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{po_id}", response_model=PurchaseOrderWithSupplierResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("purchase-orders", "po_id"),
|
||||
response_model=PurchaseOrderWithSupplierResponse
|
||||
)
|
||||
async def get_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""Get purchase order by ID with items"""
|
||||
@@ -101,9 +111,12 @@ async def get_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[PurchaseOrderResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("purchase-orders"),
|
||||
response_model=List[PurchaseOrderResponse]
|
||||
)
|
||||
async def list_purchase_orders(
|
||||
tenant_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
supplier_id: Optional[str] = Query(default=None),
|
||||
@@ -139,11 +152,14 @@ async def list_purchase_orders(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{po_id}", response_model=PurchaseOrderResponse)
|
||||
@router.patch(
|
||||
route_builder.build_resource_detail_route("purchase-orders", "po_id"),
|
||||
response_model=PurchaseOrderResponse
|
||||
)
|
||||
async def update_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
po_data: PurchaseOrderUpdate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -181,11 +197,13 @@ async def update_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{po_id}/status")
|
||||
@router.patch(
|
||||
route_builder.build_resource_action_route("purchase-orders", "po_id", "status")
|
||||
)
|
||||
async def update_order_status(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
status: str = Query(..., description="New status"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
notes: Optional[str] = Query(default=None),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
@@ -239,11 +257,14 @@ async def update_order_status(
|
||||
# APPROVAL WORKFLOW
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/approve", response_model=PurchaseOrderResponse)
|
||||
@router.post(
|
||||
route_builder.build_resource_action_route("purchase-orders", "po_id", "approve"),
|
||||
response_model=PurchaseOrderResponse
|
||||
)
|
||||
async def approve_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
approval_data: PurchaseOrderApproval,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -289,12 +310,15 @@ async def approve_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{po_id}/cancel", response_model=PurchaseOrderResponse)
|
||||
@router.post(
|
||||
route_builder.build_resource_action_route("purchase-orders", "po_id", "cancel"),
|
||||
response_model=PurchaseOrderResponse
|
||||
)
|
||||
async def cancel_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
reason: str = Query(..., description="Cancellation reason"),
|
||||
cancelled_by: Optional[str] = Query(default=None),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -335,11 +359,15 @@ async def cancel_purchase_order(
|
||||
# DELIVERY MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/deliveries", response_model=DeliveryResponse, status_code=201)
|
||||
@router.post(
|
||||
route_builder.build_nested_resource_route("purchase-orders", "po_id", "deliveries"),
|
||||
response_model=DeliveryResponse,
|
||||
status_code=201
|
||||
)
|
||||
async def create_delivery(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
delivery_data: DeliveryCreate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -375,11 +403,14 @@ async def create_delivery(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/deliveries/{delivery_id}/status")
|
||||
@router.patch(
|
||||
route_builder.build_nested_resource_route("purchase-orders", "po_id", "deliveries") + "/{delivery_id}/status"
|
||||
)
|
||||
async def update_delivery_status(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
delivery_id: str,
|
||||
status: str = Query(..., description="New delivery status"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -421,11 +452,15 @@ async def update_delivery_status(
|
||||
# INVOICE MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/invoices", response_model=SupplierInvoiceResponse, status_code=201)
|
||||
@router.post(
|
||||
route_builder.build_nested_resource_route("purchase-orders", "po_id", "invoices"),
|
||||
response_model=SupplierInvoiceResponse,
|
||||
status_code=201
|
||||
)
|
||||
async def create_invoice(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
invoice_data: SupplierInvoiceCreate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -38,18 +38,24 @@ from app.services.moq_aggregator import MOQAggregator
|
||||
from app.services.supplier_selector import SupplierSelector
|
||||
from app.core.dependencies import get_db, get_current_tenant_id
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from shared.routing import RouteBuilder
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/replenishment-plans", tags=["Replenishment Planning"])
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(tags=["replenishment-planning"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Replenishment Plan Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/generate", response_model=GenerateReplenishmentPlanResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/generate"),
|
||||
response_model=GenerateReplenishmentPlanResponse
|
||||
)
|
||||
async def generate_replenishment_plan(
|
||||
request: GenerateReplenishmentPlanRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
@@ -91,7 +97,10 @@ async def generate_replenishment_plan(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[ReplenishmentPlanSummary])
|
||||
@router.get(
|
||||
route_builder.build_operations_route("replenishment-plans"),
|
||||
response_model=List[ReplenishmentPlanSummary]
|
||||
)
|
||||
async def list_replenishment_plans(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
skip: int = Query(0, ge=0),
|
||||
@@ -123,7 +132,10 @@ async def list_replenishment_plans(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=ReplenishmentPlanResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("replenishment-plans", "plan_id"),
|
||||
response_model=ReplenishmentPlanResponse
|
||||
)
|
||||
async def get_replenishment_plan(
|
||||
plan_id: UUID = Path(...),
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
@@ -155,7 +167,10 @@ async def get_replenishment_plan(
|
||||
# Inventory Projection Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/inventory-projections/project", response_model=ProjectInventoryResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/inventory-projections/project"),
|
||||
response_model=ProjectInventoryResponse
|
||||
)
|
||||
async def project_inventory(
|
||||
request: ProjectInventoryRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -212,7 +227,10 @@ async def project_inventory(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/inventory-projections", response_model=List[InventoryProjectionResponse])
|
||||
@router.get(
|
||||
route_builder.build_operations_route("replenishment-plans/inventory-projections"),
|
||||
response_model=List[InventoryProjectionResponse]
|
||||
)
|
||||
async def list_inventory_projections(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
ingredient_id: Optional[UUID] = None,
|
||||
@@ -250,7 +268,10 @@ async def list_inventory_projections(
|
||||
# Safety Stock Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/safety-stock/calculate", response_model=SafetyStockResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/safety-stock/calculate"),
|
||||
response_model=SafetyStockResponse
|
||||
)
|
||||
async def calculate_safety_stock(
|
||||
request: SafetyStockRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -282,7 +303,10 @@ async def calculate_safety_stock(
|
||||
# Supplier Selection Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/supplier-selections/evaluate", response_model=SupplierSelectionResult)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/supplier-selections/evaluate"),
|
||||
response_model=SupplierSelectionResult
|
||||
)
|
||||
async def evaluate_supplier_selection(
|
||||
request: SupplierSelectionRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -317,7 +341,10 @@ async def evaluate_supplier_selection(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/supplier-allocations", response_model=List[SupplierAllocationResponse])
|
||||
@router.get(
|
||||
route_builder.build_operations_route("replenishment-plans/supplier-allocations"),
|
||||
response_model=List[SupplierAllocationResponse]
|
||||
)
|
||||
async def list_supplier_allocations(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
requirement_id: Optional[UUID] = None,
|
||||
@@ -353,7 +380,10 @@ async def list_supplier_allocations(
|
||||
# MOQ Aggregation Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/moq-aggregation/aggregate", response_model=MOQAggregationResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/moq-aggregation/aggregate"),
|
||||
response_model=MOQAggregationResponse
|
||||
)
|
||||
async def aggregate_for_moq(
|
||||
request: MOQAggregationRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -402,7 +432,10 @@ async def aggregate_for_moq(
|
||||
# Analytics Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.get("/analytics", response_model=ReplenishmentAnalytics)
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("replenishment-plans"),
|
||||
response_model=ReplenishmentAnalytics
|
||||
)
|
||||
async def get_replenishment_analytics(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
start_date: Optional[date] = None,
|
||||
|
||||
@@ -96,12 +96,14 @@ from app.api.purchase_orders import router as purchase_orders_router
|
||||
from app.api import replenishment # Enhanced Replenishment Planning Routes
|
||||
from app.api import analytics # Procurement Analytics Routes
|
||||
from app.api import internal_demo
|
||||
from app.api import ml_insights # ML insights endpoint
|
||||
|
||||
service.add_router(procurement_plans_router)
|
||||
service.add_router(purchase_orders_router)
|
||||
service.add_router(replenishment.router, prefix="/api/v1/tenants/{tenant_id}", tags=["replenishment"])
|
||||
service.add_router(replenishment.router, tags=["replenishment"]) # RouteBuilder already includes full path
|
||||
service.add_router(analytics.router, tags=["analytics"]) # RouteBuilder already includes full path
|
||||
service.add_router(internal_demo.router)
|
||||
service.add_router(ml_insights.router) # ML insights endpoint
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
|
||||
803
services/procurement/app/ml/price_forecaster.py
Normal file
803
services/procurement/app/ml/price_forecaster.py
Normal file
@@ -0,0 +1,803 @@
|
||||
"""
|
||||
Price Forecaster
|
||||
Predicts supplier price changes for opportunistic buying recommendations
|
||||
Identifies optimal timing for bulk purchases and price negotiation opportunities
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import structlog
|
||||
from datetime import datetime, timedelta
|
||||
from scipy import stats
|
||||
from sklearn.linear_model import LinearRegression
|
||||
from sklearn.ensemble import RandomForestRegressor
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class PriceForecaster:
|
||||
"""
|
||||
Forecasts ingredient and product prices for opportunistic procurement.
|
||||
|
||||
Capabilities:
|
||||
1. Short-term price forecasting (1-4 weeks)
|
||||
2. Seasonal price pattern detection
|
||||
3. Price trend analysis
|
||||
4. Buy/wait recommendations
|
||||
5. Bulk purchase opportunity identification
|
||||
6. Price volatility assessment
|
||||
7. Supplier comparison for price optimization
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.price_models = {}
|
||||
self.seasonal_patterns = {}
|
||||
self.volatility_scores = {}
|
||||
|
||||
async def forecast_price(
|
||||
self,
|
||||
tenant_id: str,
|
||||
ingredient_id: str,
|
||||
price_history: pd.DataFrame,
|
||||
forecast_horizon_days: int = 30,
|
||||
min_history_days: int = 180
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Forecast future prices and generate procurement recommendations.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
ingredient_id: Ingredient/product identifier
|
||||
price_history: Historical price data with columns:
|
||||
- date
|
||||
- price_per_unit
|
||||
- quantity_purchased (optional)
|
||||
- supplier_id (optional)
|
||||
forecast_horizon_days: Days to forecast ahead (default 30)
|
||||
min_history_days: Minimum days of history required (default 180)
|
||||
|
||||
Returns:
|
||||
Dictionary with price forecast and insights
|
||||
"""
|
||||
logger.info(
|
||||
"Forecasting prices",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
history_days=len(price_history),
|
||||
forecast_days=forecast_horizon_days
|
||||
)
|
||||
|
||||
# Validate input
|
||||
if len(price_history) < min_history_days:
|
||||
logger.warning(
|
||||
"Insufficient price history",
|
||||
ingredient_id=ingredient_id,
|
||||
days=len(price_history),
|
||||
required=min_history_days
|
||||
)
|
||||
return self._insufficient_data_response(
|
||||
tenant_id, ingredient_id, price_history
|
||||
)
|
||||
|
||||
# Prepare data
|
||||
price_history = price_history.copy()
|
||||
price_history['date'] = pd.to_datetime(price_history['date'])
|
||||
price_history = price_history.sort_values('date')
|
||||
|
||||
# Calculate price statistics
|
||||
price_stats = self._calculate_price_statistics(price_history)
|
||||
|
||||
# Detect seasonal patterns
|
||||
seasonal_analysis = self._detect_seasonal_patterns(price_history)
|
||||
|
||||
# Detect trends
|
||||
trend_analysis = self._analyze_price_trends(price_history)
|
||||
|
||||
# Forecast future prices
|
||||
forecast = self._generate_price_forecast(
|
||||
price_history,
|
||||
forecast_horizon_days,
|
||||
seasonal_analysis,
|
||||
trend_analysis
|
||||
)
|
||||
|
||||
# Calculate volatility
|
||||
volatility = self._calculate_price_volatility(price_history)
|
||||
|
||||
# Generate buy/wait recommendations
|
||||
recommendations = self._generate_procurement_recommendations(
|
||||
price_history,
|
||||
forecast,
|
||||
price_stats,
|
||||
volatility,
|
||||
trend_analysis
|
||||
)
|
||||
|
||||
# Identify bulk purchase opportunities
|
||||
bulk_opportunities = self._identify_bulk_opportunities(
|
||||
forecast,
|
||||
price_stats,
|
||||
volatility
|
||||
)
|
||||
|
||||
# Generate insights
|
||||
insights = self._generate_price_insights(
|
||||
tenant_id,
|
||||
ingredient_id,
|
||||
price_stats,
|
||||
forecast,
|
||||
recommendations,
|
||||
bulk_opportunities,
|
||||
trend_analysis,
|
||||
volatility
|
||||
)
|
||||
|
||||
# Store models
|
||||
self.seasonal_patterns[ingredient_id] = seasonal_analysis
|
||||
self.volatility_scores[ingredient_id] = volatility
|
||||
|
||||
logger.info(
|
||||
"Price forecasting complete",
|
||||
ingredient_id=ingredient_id,
|
||||
avg_forecast_price=forecast['mean_forecast_price'],
|
||||
recommendation=recommendations['action'],
|
||||
insights_generated=len(insights)
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': ingredient_id,
|
||||
'forecasted_at': datetime.utcnow().isoformat(),
|
||||
'history_days': len(price_history),
|
||||
'forecast_horizon_days': forecast_horizon_days,
|
||||
'price_stats': price_stats,
|
||||
'seasonal_analysis': seasonal_analysis,
|
||||
'trend_analysis': trend_analysis,
|
||||
'forecast': forecast,
|
||||
'volatility': volatility,
|
||||
'recommendations': recommendations,
|
||||
'bulk_opportunities': bulk_opportunities,
|
||||
'insights': insights
|
||||
}
|
||||
|
||||
def _calculate_price_statistics(
|
||||
self,
|
||||
price_history: pd.DataFrame
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate comprehensive price statistics.
|
||||
|
||||
Args:
|
||||
price_history: Historical price data
|
||||
|
||||
Returns:
|
||||
Dictionary of price statistics
|
||||
"""
|
||||
prices = price_history['price_per_unit'].values
|
||||
|
||||
# Basic statistics
|
||||
current_price = float(prices[-1])
|
||||
mean_price = float(prices.mean())
|
||||
std_price = float(prices.std())
|
||||
cv_price = (std_price / mean_price) if mean_price > 0 else 0
|
||||
|
||||
# Price range
|
||||
min_price = float(prices.min())
|
||||
max_price = float(prices.max())
|
||||
price_range_pct = ((max_price - min_price) / mean_price * 100) if mean_price > 0 else 0
|
||||
|
||||
# Recent vs historical
|
||||
if len(prices) >= 60:
|
||||
recent_30d_mean = float(prices[-30:].mean())
|
||||
historical_mean = float(prices[:-30].mean())
|
||||
price_change_pct = ((recent_30d_mean - historical_mean) / historical_mean * 100) if historical_mean > 0 else 0
|
||||
else:
|
||||
recent_30d_mean = current_price
|
||||
price_change_pct = 0
|
||||
|
||||
# Price momentum (last 7 days vs previous 7 days)
|
||||
if len(prices) >= 14:
|
||||
last_week = prices[-7:].mean()
|
||||
prev_week = prices[-14:-7].mean()
|
||||
momentum = ((last_week - prev_week) / prev_week * 100) if prev_week > 0 else 0
|
||||
else:
|
||||
momentum = 0
|
||||
|
||||
return {
|
||||
'current_price': current_price,
|
||||
'mean_price': mean_price,
|
||||
'std_price': std_price,
|
||||
'cv_price': cv_price,
|
||||
'min_price': min_price,
|
||||
'max_price': max_price,
|
||||
'price_range_pct': price_range_pct,
|
||||
'recent_30d_mean': recent_30d_mean,
|
||||
'price_change_30d_pct': price_change_pct,
|
||||
'momentum_7d_pct': momentum,
|
||||
'data_points': len(prices)
|
||||
}
|
||||
|
||||
def _detect_seasonal_patterns(
|
||||
self,
|
||||
price_history: pd.DataFrame
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Detect seasonal price patterns.
|
||||
|
||||
Args:
|
||||
price_history: Historical price data
|
||||
|
||||
Returns:
|
||||
Seasonal pattern analysis
|
||||
"""
|
||||
# Extract month from date
|
||||
price_history = price_history.copy()
|
||||
price_history['month'] = price_history['date'].dt.month
|
||||
|
||||
# Calculate average price per month
|
||||
monthly_avg = price_history.groupby('month')['price_per_unit'].agg(['mean', 'std', 'count'])
|
||||
|
||||
overall_mean = price_history['price_per_unit'].mean()
|
||||
|
||||
seasonal_patterns = {}
|
||||
has_seasonality = False
|
||||
|
||||
month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
for month in range(1, 13):
|
||||
if month in monthly_avg.index and monthly_avg.loc[month, 'count'] >= 3:
|
||||
month_mean = monthly_avg.loc[month, 'mean']
|
||||
deviation_pct = ((month_mean - overall_mean) / overall_mean * 100) if overall_mean > 0 else 0
|
||||
|
||||
seasonal_patterns[month_names[month-1]] = {
|
||||
'month': month,
|
||||
'avg_price': round(float(month_mean), 2),
|
||||
'deviation_pct': round(float(deviation_pct), 2),
|
||||
'sample_size': int(monthly_avg.loc[month, 'count'])
|
||||
}
|
||||
|
||||
# Significant seasonality if >10% deviation
|
||||
if abs(deviation_pct) > 10:
|
||||
has_seasonality = True
|
||||
|
||||
return {
|
||||
'has_seasonality': has_seasonality,
|
||||
'monthly_patterns': seasonal_patterns,
|
||||
'overall_mean_price': round(float(overall_mean), 2)
|
||||
}
|
||||
|
||||
def _analyze_price_trends(
|
||||
self,
|
||||
price_history: pd.DataFrame
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze price trends using linear regression.
|
||||
|
||||
Args:
|
||||
price_history: Historical price data
|
||||
|
||||
Returns:
|
||||
Trend analysis
|
||||
"""
|
||||
# Create time index (days from start)
|
||||
price_history = price_history.copy()
|
||||
price_history['days_from_start'] = (
|
||||
price_history['date'] - price_history['date'].min()
|
||||
).dt.days
|
||||
|
||||
X = price_history['days_from_start'].values.reshape(-1, 1)
|
||||
y = price_history['price_per_unit'].values
|
||||
|
||||
# Fit linear regression
|
||||
model = LinearRegression()
|
||||
model.fit(X, y)
|
||||
|
||||
# Calculate trend
|
||||
slope = float(model.coef_[0])
|
||||
intercept = float(model.intercept_)
|
||||
r_squared = float(model.score(X, y))
|
||||
|
||||
# Trend direction and magnitude
|
||||
avg_price = y.mean()
|
||||
trend_pct_per_month = (slope * 30 / avg_price * 100) if avg_price > 0 else 0
|
||||
|
||||
# Classify trend
|
||||
if abs(trend_pct_per_month) < 2:
|
||||
trend_direction = 'stable'
|
||||
elif trend_pct_per_month > 2:
|
||||
trend_direction = 'increasing'
|
||||
else:
|
||||
trend_direction = 'decreasing'
|
||||
|
||||
# Recent trend (last 90 days)
|
||||
if len(price_history) >= 90:
|
||||
recent_data = price_history.tail(90).copy()
|
||||
recent_X = recent_data['days_from_start'].values.reshape(-1, 1)
|
||||
recent_y = recent_data['price_per_unit'].values
|
||||
|
||||
recent_model = LinearRegression()
|
||||
recent_model.fit(recent_X, recent_y)
|
||||
|
||||
recent_slope = float(recent_model.coef_[0])
|
||||
recent_trend_pct = (recent_slope * 30 / recent_y.mean() * 100) if recent_y.mean() > 0 else 0
|
||||
else:
|
||||
recent_trend_pct = trend_pct_per_month
|
||||
|
||||
return {
|
||||
'trend_direction': trend_direction,
|
||||
'trend_pct_per_month': round(trend_pct_per_month, 2),
|
||||
'recent_trend_pct_per_month': round(recent_trend_pct, 2),
|
||||
'slope': round(slope, 4),
|
||||
'r_squared': round(r_squared, 3),
|
||||
'is_accelerating': abs(recent_trend_pct) > abs(trend_pct_per_month) * 1.5
|
||||
}
|
||||
|
||||
def _generate_price_forecast(
|
||||
self,
|
||||
price_history: pd.DataFrame,
|
||||
forecast_days: int,
|
||||
seasonal_analysis: Dict[str, Any],
|
||||
trend_analysis: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate price forecast for specified horizon.
|
||||
|
||||
Args:
|
||||
price_history: Historical price data
|
||||
forecast_days: Days to forecast
|
||||
seasonal_analysis: Seasonal patterns
|
||||
trend_analysis: Trend analysis
|
||||
|
||||
Returns:
|
||||
Price forecast
|
||||
"""
|
||||
current_price = price_history['price_per_unit'].iloc[-1]
|
||||
current_date = price_history['date'].iloc[-1]
|
||||
|
||||
# Simple forecast: current price + trend + seasonal adjustment
|
||||
trend_slope = trend_analysis['slope']
|
||||
|
||||
forecast_prices = []
|
||||
forecast_dates = []
|
||||
|
||||
for day in range(1, forecast_days + 1):
|
||||
forecast_date = current_date + timedelta(days=day)
|
||||
forecast_dates.append(forecast_date)
|
||||
|
||||
# Base forecast from trend
|
||||
base_forecast = current_price + (trend_slope * day)
|
||||
|
||||
# Seasonal adjustment
|
||||
if seasonal_analysis['has_seasonality']:
|
||||
month_name = forecast_date.strftime('%b')
|
||||
if month_name in seasonal_analysis['monthly_patterns']:
|
||||
month_deviation = seasonal_analysis['monthly_patterns'][month_name]['deviation_pct']
|
||||
seasonal_adjustment = base_forecast * (month_deviation / 100)
|
||||
base_forecast += seasonal_adjustment
|
||||
|
||||
forecast_prices.append(base_forecast)
|
||||
|
||||
forecast_prices = np.array(forecast_prices)
|
||||
|
||||
# Calculate confidence intervals (±2 std)
|
||||
historical_std = price_history['price_per_unit'].std()
|
||||
lower_bound = forecast_prices - 2 * historical_std
|
||||
upper_bound = forecast_prices + 2 * historical_std
|
||||
|
||||
return {
|
||||
'forecast_dates': [d.strftime('%Y-%m-%d') for d in forecast_dates],
|
||||
'forecast_prices': [round(float(p), 2) for p in forecast_prices],
|
||||
'lower_bound': [round(float(p), 2) for p in lower_bound],
|
||||
'upper_bound': [round(float(p), 2) for p in upper_bound],
|
||||
'mean_forecast_price': round(float(forecast_prices.mean()), 2),
|
||||
'min_forecast_price': round(float(forecast_prices.min()), 2),
|
||||
'max_forecast_price': round(float(forecast_prices.max()), 2),
|
||||
'confidence': self._calculate_forecast_confidence(price_history, trend_analysis)
|
||||
}
|
||||
|
||||
def _calculate_forecast_confidence(
|
||||
self,
|
||||
price_history: pd.DataFrame,
|
||||
trend_analysis: Dict[str, Any]
|
||||
) -> int:
|
||||
"""Calculate confidence in price forecast (0-100)."""
|
||||
confidence = 50 # Base confidence
|
||||
|
||||
# More data = higher confidence
|
||||
data_points = len(price_history)
|
||||
if data_points >= 365:
|
||||
confidence += 30
|
||||
elif data_points >= 180:
|
||||
confidence += 20
|
||||
else:
|
||||
confidence += 10
|
||||
|
||||
# Strong trend = higher confidence
|
||||
r_squared = trend_analysis['r_squared']
|
||||
if r_squared > 0.7:
|
||||
confidence += 20
|
||||
elif r_squared > 0.5:
|
||||
confidence += 10
|
||||
|
||||
# Low volatility = higher confidence
|
||||
cv = price_history['price_per_unit'].std() / price_history['price_per_unit'].mean()
|
||||
if cv < 0.1:
|
||||
confidence += 10
|
||||
elif cv < 0.2:
|
||||
confidence += 5
|
||||
|
||||
return min(100, confidence)
|
||||
|
||||
def _calculate_price_volatility(
|
||||
self,
|
||||
price_history: pd.DataFrame
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate price volatility metrics.
|
||||
|
||||
Args:
|
||||
price_history: Historical price data
|
||||
|
||||
Returns:
|
||||
Volatility analysis
|
||||
"""
|
||||
prices = price_history['price_per_unit'].values
|
||||
|
||||
# Coefficient of variation
|
||||
cv = float(prices.std() / prices.mean()) if prices.mean() > 0 else 0
|
||||
|
||||
# Price changes (day-to-day)
|
||||
price_changes = np.diff(prices)
|
||||
pct_changes = (price_changes / prices[:-1] * 100)
|
||||
|
||||
# Volatility classification
|
||||
if cv < 0.1:
|
||||
volatility_level = 'low'
|
||||
elif cv < 0.2:
|
||||
volatility_level = 'medium'
|
||||
else:
|
||||
volatility_level = 'high'
|
||||
|
||||
return {
|
||||
'coefficient_of_variation': round(cv, 3),
|
||||
'volatility_level': volatility_level,
|
||||
'avg_daily_change_pct': round(float(np.abs(pct_changes).mean()), 2),
|
||||
'max_daily_increase_pct': round(float(pct_changes.max()), 2),
|
||||
'max_daily_decrease_pct': round(float(pct_changes.min()), 2)
|
||||
}
|
||||
|
||||
def _generate_procurement_recommendations(
|
||||
self,
|
||||
price_history: pd.DataFrame,
|
||||
forecast: Dict[str, Any],
|
||||
price_stats: Dict[str, float],
|
||||
volatility: Dict[str, Any],
|
||||
trend_analysis: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate buy/wait recommendations based on forecast.
|
||||
|
||||
Args:
|
||||
price_history: Historical data
|
||||
forecast: Price forecast
|
||||
price_stats: Price statistics
|
||||
volatility: Volatility analysis
|
||||
trend_analysis: Trend analysis
|
||||
|
||||
Returns:
|
||||
Procurement recommendations
|
||||
"""
|
||||
current_price = price_stats['current_price']
|
||||
forecast_mean = forecast['mean_forecast_price']
|
||||
forecast_min = forecast['min_forecast_price']
|
||||
|
||||
# Calculate expected price change
|
||||
expected_change_pct = ((forecast_mean - current_price) / current_price * 100) if current_price > 0 else 0
|
||||
|
||||
# Decision logic
|
||||
if expected_change_pct < -5:
|
||||
# Price expected to drop >5%
|
||||
action = 'wait'
|
||||
reasoning = f'Price expected to decrease by {abs(expected_change_pct):.1f}% in next 30 days. Delay purchase.'
|
||||
urgency = 'low'
|
||||
|
||||
elif expected_change_pct > 5:
|
||||
# Price expected to increase >5%
|
||||
action = 'buy_now'
|
||||
reasoning = f'Price expected to increase by {expected_change_pct:.1f}% in next 30 days. Purchase soon.'
|
||||
urgency = 'high'
|
||||
|
||||
elif volatility['volatility_level'] == 'high':
|
||||
# High volatility - wait for dip
|
||||
action = 'wait_for_dip'
|
||||
reasoning = f'High price volatility (CV={volatility["coefficient_of_variation"]:.2f}). Wait for favorable dip.'
|
||||
urgency = 'medium'
|
||||
|
||||
elif current_price < price_stats['mean_price'] * 0.95:
|
||||
# Currently below average
|
||||
action = 'buy_now'
|
||||
reasoning = f'Current price €{current_price:.2f} is {((price_stats["mean_price"] - current_price) / price_stats["mean_price"] * 100):.1f}% below average. Good buying opportunity.'
|
||||
urgency = 'medium'
|
||||
|
||||
else:
|
||||
# Neutral
|
||||
action = 'normal_purchase'
|
||||
reasoning = 'Price stable. Follow normal procurement schedule.'
|
||||
urgency = 'low'
|
||||
|
||||
# Optimal purchase timing
|
||||
min_price_index = forecast['forecast_prices'].index(forecast_min)
|
||||
optimal_date = forecast['forecast_dates'][min_price_index]
|
||||
|
||||
return {
|
||||
'action': action,
|
||||
'reasoning': reasoning,
|
||||
'urgency': urgency,
|
||||
'expected_price_change_pct': round(expected_change_pct, 2),
|
||||
'current_price': current_price,
|
||||
'forecast_mean_price': forecast_mean,
|
||||
'forecast_min_price': forecast_min,
|
||||
'optimal_purchase_date': optimal_date,
|
||||
'days_until_optimal': min_price_index + 1
|
||||
}
|
||||
|
||||
def _identify_bulk_opportunities(
|
||||
self,
|
||||
forecast: Dict[str, Any],
|
||||
price_stats: Dict[str, float],
|
||||
volatility: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Identify bulk purchase opportunities.
|
||||
|
||||
Args:
|
||||
forecast: Price forecast
|
||||
price_stats: Price statistics
|
||||
volatility: Volatility analysis
|
||||
|
||||
Returns:
|
||||
Bulk opportunity analysis
|
||||
"""
|
||||
current_price = price_stats['current_price']
|
||||
forecast_max = forecast['max_forecast_price']
|
||||
|
||||
# Potential savings from bulk buy at current price
|
||||
if forecast_max > current_price:
|
||||
potential_savings_pct = ((forecast_max - current_price) / current_price * 100)
|
||||
|
||||
if potential_savings_pct > 10:
|
||||
opportunity_level = 'high'
|
||||
elif potential_savings_pct > 5:
|
||||
opportunity_level = 'medium'
|
||||
else:
|
||||
opportunity_level = 'low'
|
||||
|
||||
has_opportunity = potential_savings_pct > 5
|
||||
|
||||
else:
|
||||
potential_savings_pct = 0
|
||||
opportunity_level = 'none'
|
||||
has_opportunity = False
|
||||
|
||||
return {
|
||||
'has_bulk_opportunity': has_opportunity,
|
||||
'opportunity_level': opportunity_level,
|
||||
'potential_savings_pct': round(potential_savings_pct, 2),
|
||||
'recommended_bulk_quantity_months': 2 if has_opportunity and volatility['volatility_level'] != 'high' else 1
|
||||
}
|
||||
|
||||
def _generate_price_insights(
|
||||
self,
|
||||
tenant_id: str,
|
||||
ingredient_id: str,
|
||||
price_stats: Dict[str, float],
|
||||
forecast: Dict[str, Any],
|
||||
recommendations: Dict[str, Any],
|
||||
bulk_opportunities: Dict[str, Any],
|
||||
trend_analysis: Dict[str, Any],
|
||||
volatility: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate actionable pricing insights.
|
||||
|
||||
Returns:
|
||||
List of insights
|
||||
"""
|
||||
insights = []
|
||||
|
||||
# Insight 1: Buy now recommendation
|
||||
if recommendations['action'] == 'buy_now':
|
||||
insights.append({
|
||||
'type': 'recommendation',
|
||||
'priority': recommendations['urgency'],
|
||||
'category': 'procurement',
|
||||
'title': f'Buy Now: Price Increasing {recommendations["expected_price_change_pct"]:.1f}%',
|
||||
'description': recommendations['reasoning'],
|
||||
'impact_type': 'cost_avoidance',
|
||||
'impact_value': abs(recommendations['expected_price_change_pct']),
|
||||
'impact_unit': 'percentage',
|
||||
'confidence': forecast['confidence'],
|
||||
'metrics_json': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'current_price': price_stats['current_price'],
|
||||
'forecast_price': forecast['mean_forecast_price'],
|
||||
'expected_change_pct': recommendations['expected_price_change_pct'],
|
||||
'optimal_date': recommendations['optimal_purchase_date']
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Purchase Now',
|
||||
'action': 'create_purchase_order',
|
||||
'params': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'priority': 'high'
|
||||
}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'price_forecaster'
|
||||
})
|
||||
|
||||
# Insight 2: Wait recommendation
|
||||
elif recommendations['action'] == 'wait':
|
||||
insights.append({
|
||||
'type': 'recommendation',
|
||||
'priority': 'medium',
|
||||
'category': 'procurement',
|
||||
'title': f'Wait to Buy: Price Decreasing {abs(recommendations["expected_price_change_pct"]):.1f}%',
|
||||
'description': recommendations['reasoning'] + f' Optimal purchase date: {recommendations["optimal_purchase_date"]}.',
|
||||
'impact_type': 'cost_savings',
|
||||
'impact_value': abs(recommendations['expected_price_change_pct']),
|
||||
'impact_unit': 'percentage',
|
||||
'confidence': forecast['confidence'],
|
||||
'metrics_json': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'current_price': price_stats['current_price'],
|
||||
'forecast_min_price': forecast['min_forecast_price'],
|
||||
'optimal_date': recommendations['optimal_purchase_date'],
|
||||
'days_until_optimal': recommendations['days_until_optimal']
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Delay Purchase',
|
||||
'action': 'delay_purchase_order',
|
||||
'params': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'delay_days': recommendations['days_until_optimal']
|
||||
}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'price_forecaster'
|
||||
})
|
||||
|
||||
# Insight 3: Bulk opportunity
|
||||
if bulk_opportunities['has_bulk_opportunity']:
|
||||
insights.append({
|
||||
'type': 'optimization',
|
||||
'priority': bulk_opportunities['opportunity_level'],
|
||||
'category': 'procurement',
|
||||
'title': f'Bulk Buy Opportunity: Save {bulk_opportunities["potential_savings_pct"]:.1f}%',
|
||||
'description': f'Current price is favorable. Purchasing {bulk_opportunities["recommended_bulk_quantity_months"]} months supply now could save {bulk_opportunities["potential_savings_pct"]:.1f}% vs future prices.',
|
||||
'impact_type': 'cost_savings',
|
||||
'impact_value': bulk_opportunities['potential_savings_pct'],
|
||||
'impact_unit': 'percentage',
|
||||
'confidence': forecast['confidence'],
|
||||
'metrics_json': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'current_price': price_stats['current_price'],
|
||||
'forecast_max_price': forecast['max_forecast_price'],
|
||||
'savings_pct': bulk_opportunities['potential_savings_pct'],
|
||||
'recommended_months_supply': bulk_opportunities['recommended_bulk_quantity_months']
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Create Bulk Order',
|
||||
'action': 'create_bulk_purchase_order',
|
||||
'params': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'months_supply': bulk_opportunities['recommended_bulk_quantity_months']
|
||||
}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'price_forecaster'
|
||||
})
|
||||
|
||||
# Insight 4: High volatility warning
|
||||
if volatility['volatility_level'] == 'high':
|
||||
insights.append({
|
||||
'type': 'alert',
|
||||
'priority': 'medium',
|
||||
'category': 'procurement',
|
||||
'title': f'High Price Volatility: CV={volatility["coefficient_of_variation"]:.2f}',
|
||||
'description': f'Ingredient {ingredient_id} shows high price volatility with {volatility["avg_daily_change_pct"]:.1f}% average daily change. Consider alternative suppliers or hedge strategies.',
|
||||
'impact_type': 'risk_warning',
|
||||
'impact_value': volatility['coefficient_of_variation'],
|
||||
'impact_unit': 'cv_score',
|
||||
'confidence': 90,
|
||||
'metrics_json': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'volatility_level': volatility['volatility_level'],
|
||||
'cv': volatility['coefficient_of_variation'],
|
||||
'avg_daily_change_pct': volatility['avg_daily_change_pct']
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Find Alternative Suppliers',
|
||||
'action': 'search_alternative_suppliers',
|
||||
'params': {'ingredient_id': ingredient_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'price_forecaster'
|
||||
})
|
||||
|
||||
# Insight 5: Strong price trend
|
||||
if abs(trend_analysis['trend_pct_per_month']) > 5:
|
||||
direction = 'increasing' if trend_analysis['trend_pct_per_month'] > 0 else 'decreasing'
|
||||
insights.append({
|
||||
'type': 'insight',
|
||||
'priority': 'medium',
|
||||
'category': 'procurement',
|
||||
'title': f'Strong Price Trend: {direction.title()} {abs(trend_analysis["trend_pct_per_month"]):.1f}%/month',
|
||||
'description': f'Ingredient {ingredient_id} prices are {direction} at {abs(trend_analysis["trend_pct_per_month"]):.1f}% per month. Plan procurement strategy accordingly.',
|
||||
'impact_type': 'trend_warning',
|
||||
'impact_value': abs(trend_analysis['trend_pct_per_month']),
|
||||
'impact_unit': 'pct_per_month',
|
||||
'confidence': int(trend_analysis['r_squared'] * 100),
|
||||
'metrics_json': {
|
||||
'ingredient_id': ingredient_id,
|
||||
'trend_direction': trend_analysis['trend_direction'],
|
||||
'trend_pct_per_month': trend_analysis['trend_pct_per_month'],
|
||||
'r_squared': trend_analysis['r_squared']
|
||||
},
|
||||
'actionable': False,
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'price_forecaster'
|
||||
})
|
||||
|
||||
return insights
|
||||
|
||||
def _insufficient_data_response(
|
||||
self,
|
||||
tenant_id: str,
|
||||
ingredient_id: str,
|
||||
price_history: pd.DataFrame
|
||||
) -> Dict[str, Any]:
|
||||
"""Return response when insufficient data available."""
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': ingredient_id,
|
||||
'forecasted_at': datetime.utcnow().isoformat(),
|
||||
'history_days': len(price_history),
|
||||
'forecast_horizon_days': 0,
|
||||
'price_stats': {},
|
||||
'seasonal_analysis': {'has_seasonality': False},
|
||||
'trend_analysis': {},
|
||||
'forecast': {},
|
||||
'volatility': {},
|
||||
'recommendations': {
|
||||
'action': 'insufficient_data',
|
||||
'reasoning': 'Not enough price history for reliable forecast. Need at least 180 days.',
|
||||
'urgency': 'low'
|
||||
},
|
||||
'bulk_opportunities': {'has_bulk_opportunity': False},
|
||||
'insights': []
|
||||
}
|
||||
|
||||
def get_seasonal_patterns(self, ingredient_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached seasonal patterns for an ingredient."""
|
||||
return self.seasonal_patterns.get(ingredient_id)
|
||||
|
||||
def get_volatility_score(self, ingredient_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached volatility score for an ingredient."""
|
||||
return self.volatility_scores.get(ingredient_id)
|
||||
371
services/procurement/app/ml/price_insights_orchestrator.py
Normal file
371
services/procurement/app/ml/price_insights_orchestrator.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Price Insights Orchestrator
|
||||
Coordinates price forecasting and insight posting
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Any, Optional
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add shared clients to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
from shared.clients.ai_insights_client import AIInsightsClient
|
||||
|
||||
from app.ml.price_forecaster import PriceForecaster
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class PriceInsightsOrchestrator:
|
||||
"""
|
||||
Orchestrates price forecasting and insight generation workflow.
|
||||
|
||||
Workflow:
|
||||
1. Forecast prices from historical data
|
||||
2. Generate buy/wait/bulk recommendations
|
||||
3. Post insights to AI Insights Service
|
||||
4. Provide price forecasts for procurement planning
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_insights_base_url: str = "http://ai-insights-service:8000"
|
||||
):
|
||||
self.forecaster = PriceForecaster()
|
||||
self.ai_insights_client = AIInsightsClient(ai_insights_base_url)
|
||||
|
||||
async def forecast_and_post_insights(
|
||||
self,
|
||||
tenant_id: str,
|
||||
ingredient_id: str,
|
||||
price_history: pd.DataFrame,
|
||||
forecast_horizon_days: int = 30,
|
||||
min_history_days: int = 180
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Complete workflow: Forecast prices and post insights.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
ingredient_id: Ingredient identifier
|
||||
price_history: Historical price data
|
||||
forecast_horizon_days: Days to forecast ahead
|
||||
min_history_days: Minimum days of history required
|
||||
|
||||
Returns:
|
||||
Workflow results with forecast and posted insights
|
||||
"""
|
||||
logger.info(
|
||||
"Starting price forecasting workflow",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
history_days=len(price_history)
|
||||
)
|
||||
|
||||
# Step 1: Forecast prices
|
||||
forecast_results = await self.forecaster.forecast_price(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
price_history=price_history,
|
||||
forecast_horizon_days=forecast_horizon_days,
|
||||
min_history_days=min_history_days
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Price forecasting complete",
|
||||
ingredient_id=ingredient_id,
|
||||
recommendation=forecast_results.get('recommendations', {}).get('action'),
|
||||
insights_generated=len(forecast_results.get('insights', []))
|
||||
)
|
||||
|
||||
# Step 2: Enrich insights with tenant_id and ingredient context
|
||||
enriched_insights = self._enrich_insights(
|
||||
forecast_results.get('insights', []),
|
||||
tenant_id,
|
||||
ingredient_id
|
||||
)
|
||||
|
||||
# Step 3: Post insights to AI Insights Service
|
||||
if enriched_insights:
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_insights
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Price insights posted to AI Insights Service",
|
||||
ingredient_id=ingredient_id,
|
||||
total=post_results['total'],
|
||||
successful=post_results['successful'],
|
||||
failed=post_results['failed']
|
||||
)
|
||||
else:
|
||||
post_results = {'total': 0, 'successful': 0, 'failed': 0}
|
||||
logger.info("No insights to post for ingredient", ingredient_id=ingredient_id)
|
||||
|
||||
# Step 4: Return comprehensive results
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': ingredient_id,
|
||||
'forecasted_at': forecast_results['forecasted_at'],
|
||||
'history_days': forecast_results['history_days'],
|
||||
'forecast': forecast_results.get('forecast', {}),
|
||||
'recommendation': forecast_results.get('recommendations', {}),
|
||||
'bulk_opportunity': forecast_results.get('bulk_opportunities', {}),
|
||||
'insights_generated': len(enriched_insights),
|
||||
'insights_posted': post_results['successful'],
|
||||
'insights_failed': post_results['failed'],
|
||||
'created_insights': post_results.get('created_insights', [])
|
||||
}
|
||||
|
||||
def _enrich_insights(
|
||||
self,
|
||||
insights: List[Dict[str, Any]],
|
||||
tenant_id: str,
|
||||
ingredient_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enrich insights with required fields for AI Insights Service.
|
||||
|
||||
Args:
|
||||
insights: Raw insights from forecaster
|
||||
tenant_id: Tenant identifier
|
||||
ingredient_id: Ingredient identifier
|
||||
|
||||
Returns:
|
||||
Enriched insights ready for posting
|
||||
"""
|
||||
enriched = []
|
||||
|
||||
for insight in insights:
|
||||
# Add required tenant_id
|
||||
enriched_insight = insight.copy()
|
||||
enriched_insight['tenant_id'] = tenant_id
|
||||
|
||||
# Add ingredient context to metrics
|
||||
if 'metrics_json' not in enriched_insight:
|
||||
enriched_insight['metrics_json'] = {}
|
||||
|
||||
enriched_insight['metrics_json']['ingredient_id'] = ingredient_id
|
||||
|
||||
# Add source metadata
|
||||
enriched_insight['source_service'] = 'procurement'
|
||||
enriched_insight['source_model'] = 'price_forecaster'
|
||||
enriched_insight['detected_at'] = datetime.utcnow().isoformat()
|
||||
|
||||
enriched.append(enriched_insight)
|
||||
|
||||
return enriched
|
||||
|
||||
async def forecast_all_ingredients(
|
||||
self,
|
||||
tenant_id: str,
|
||||
ingredients_data: Dict[str, pd.DataFrame],
|
||||
forecast_horizon_days: int = 30,
|
||||
min_history_days: int = 180
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Forecast prices for all ingredients for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
ingredients_data: Dict of {ingredient_id: price_history DataFrame}
|
||||
forecast_horizon_days: Days to forecast
|
||||
min_history_days: Minimum history required
|
||||
|
||||
Returns:
|
||||
Comprehensive forecasting results
|
||||
"""
|
||||
logger.info(
|
||||
"Forecasting prices for all ingredients",
|
||||
tenant_id=tenant_id,
|
||||
ingredients=len(ingredients_data)
|
||||
)
|
||||
|
||||
all_results = []
|
||||
total_insights_posted = 0
|
||||
buy_now_count = 0
|
||||
wait_count = 0
|
||||
bulk_opportunity_count = 0
|
||||
|
||||
# Forecast each ingredient
|
||||
for ingredient_id, price_history in ingredients_data.items():
|
||||
try:
|
||||
results = await self.forecast_and_post_insights(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
price_history=price_history,
|
||||
forecast_horizon_days=forecast_horizon_days,
|
||||
min_history_days=min_history_days
|
||||
)
|
||||
|
||||
all_results.append(results)
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
# Count recommendations
|
||||
action = results['recommendation'].get('action')
|
||||
if action == 'buy_now':
|
||||
buy_now_count += 1
|
||||
elif action in ['wait', 'wait_for_dip']:
|
||||
wait_count += 1
|
||||
|
||||
if results['bulk_opportunity'].get('has_bulk_opportunity'):
|
||||
bulk_opportunity_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error forecasting ingredient",
|
||||
ingredient_id=ingredient_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Generate summary insight
|
||||
if buy_now_count > 0 or bulk_opportunity_count > 0:
|
||||
summary_insight = self._generate_portfolio_summary_insight(
|
||||
tenant_id, all_results, buy_now_count, wait_count, bulk_opportunity_count
|
||||
)
|
||||
|
||||
if summary_insight:
|
||||
enriched_summary = self._enrich_insights(
|
||||
[summary_insight], tenant_id, 'all_ingredients'
|
||||
)
|
||||
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_summary
|
||||
)
|
||||
|
||||
total_insights_posted += post_results['successful']
|
||||
|
||||
logger.info(
|
||||
"All ingredients forecasting complete",
|
||||
tenant_id=tenant_id,
|
||||
ingredients_forecasted=len(all_results),
|
||||
total_insights_posted=total_insights_posted,
|
||||
buy_now_recommendations=buy_now_count,
|
||||
bulk_opportunities=bulk_opportunity_count
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'forecasted_at': datetime.utcnow().isoformat(),
|
||||
'ingredients_forecasted': len(all_results),
|
||||
'ingredient_results': all_results,
|
||||
'total_insights_posted': total_insights_posted,
|
||||
'buy_now_count': buy_now_count,
|
||||
'wait_count': wait_count,
|
||||
'bulk_opportunity_count': bulk_opportunity_count
|
||||
}
|
||||
|
||||
def _generate_portfolio_summary_insight(
|
||||
self,
|
||||
tenant_id: str,
|
||||
all_results: List[Dict[str, Any]],
|
||||
buy_now_count: int,
|
||||
wait_count: int,
|
||||
bulk_opportunity_count: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Generate portfolio-level summary insight.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
all_results: All ingredient forecast results
|
||||
buy_now_count: Number of buy now recommendations
|
||||
wait_count: Number of wait recommendations
|
||||
bulk_opportunity_count: Number of bulk opportunities
|
||||
|
||||
Returns:
|
||||
Summary insight or None
|
||||
"""
|
||||
if buy_now_count == 0 and bulk_opportunity_count == 0:
|
||||
return None
|
||||
|
||||
# Calculate potential savings from bulk opportunities
|
||||
total_potential_savings = 0
|
||||
for result in all_results:
|
||||
bulk_opp = result.get('bulk_opportunity', {})
|
||||
if bulk_opp.get('has_bulk_opportunity'):
|
||||
# Estimate savings (simplified)
|
||||
savings_pct = bulk_opp.get('potential_savings_pct', 0)
|
||||
total_potential_savings += savings_pct
|
||||
|
||||
avg_potential_savings = total_potential_savings / max(1, bulk_opportunity_count)
|
||||
|
||||
description_parts = []
|
||||
if buy_now_count > 0:
|
||||
description_parts.append(f'{buy_now_count} ingredients show price increases - purchase soon')
|
||||
if bulk_opportunity_count > 0:
|
||||
description_parts.append(f'{bulk_opportunity_count} ingredients have bulk buying opportunities (avg {avg_potential_savings:.1f}% savings)')
|
||||
|
||||
return {
|
||||
'type': 'recommendation',
|
||||
'priority': 'high' if buy_now_count > 2 else 'medium',
|
||||
'category': 'procurement',
|
||||
'title': f'Procurement Timing Opportunities: {buy_now_count + bulk_opportunity_count} Items',
|
||||
'description': 'Price forecast analysis identified procurement timing opportunities. ' + '. '.join(description_parts) + '.',
|
||||
'impact_type': 'cost_optimization',
|
||||
'impact_value': avg_potential_savings if bulk_opportunity_count > 0 else buy_now_count,
|
||||
'impact_unit': 'percentage' if bulk_opportunity_count > 0 else 'items',
|
||||
'confidence': 75,
|
||||
'metrics_json': {
|
||||
'ingredients_analyzed': len(all_results),
|
||||
'buy_now_count': buy_now_count,
|
||||
'wait_count': wait_count,
|
||||
'bulk_opportunity_count': bulk_opportunity_count,
|
||||
'avg_potential_savings_pct': round(avg_potential_savings, 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Review Price Forecasts',
|
||||
'action': 'review_price_forecasts',
|
||||
'params': {'tenant_id': tenant_id}
|
||||
},
|
||||
{
|
||||
'label': 'Create Optimized Orders',
|
||||
'action': 'create_optimized_purchase_orders',
|
||||
'params': {'tenant_id': tenant_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'price_forecaster'
|
||||
}
|
||||
|
||||
async def get_price_forecast(
|
||||
self,
|
||||
ingredient_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get cached seasonal patterns for an ingredient.
|
||||
|
||||
Args:
|
||||
ingredient_id: Ingredient identifier
|
||||
|
||||
Returns:
|
||||
Seasonal patterns or None if not forecasted
|
||||
"""
|
||||
return self.forecaster.get_seasonal_patterns(ingredient_id)
|
||||
|
||||
async def get_volatility_assessment(
|
||||
self,
|
||||
ingredient_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get cached volatility assessment for an ingredient.
|
||||
|
||||
Args:
|
||||
ingredient_id: Ingredient identifier
|
||||
|
||||
Returns:
|
||||
Volatility assessment or None if not assessed
|
||||
"""
|
||||
return self.forecaster.get_volatility_score(ingredient_id)
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client connections."""
|
||||
await self.ai_insights_client.close()
|
||||
320
services/procurement/app/ml/supplier_insights_orchestrator.py
Normal file
320
services/procurement/app/ml/supplier_insights_orchestrator.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Supplier Insights Orchestrator
|
||||
Coordinates supplier performance analysis and insight posting
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from typing import Dict, List, Any, Optional
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add shared clients to path
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../..'))
|
||||
from shared.clients.ai_insights_client import AIInsightsClient
|
||||
|
||||
from app.ml.supplier_performance_predictor import SupplierPerformancePredictor
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SupplierInsightsOrchestrator:
|
||||
"""
|
||||
Orchestrates supplier performance analysis and insight generation workflow.
|
||||
|
||||
Workflow:
|
||||
1. Analyze supplier performance from historical orders
|
||||
2. Generate insights for procurement risk management
|
||||
3. Post insights to AI Insights Service
|
||||
4. Provide supplier comparison and recommendations
|
||||
5. Track supplier reliability scores
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ai_insights_base_url: str = "http://ai-insights-service:8000"
|
||||
):
|
||||
self.predictor = SupplierPerformancePredictor()
|
||||
self.ai_insights_client = AIInsightsClient(ai_insights_base_url)
|
||||
|
||||
async def analyze_and_post_supplier_insights(
|
||||
self,
|
||||
tenant_id: str,
|
||||
supplier_id: str,
|
||||
order_history: pd.DataFrame,
|
||||
min_orders: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Complete workflow: Analyze supplier and post insights.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
supplier_id: Supplier identifier
|
||||
order_history: Historical order data
|
||||
min_orders: Minimum orders for analysis
|
||||
|
||||
Returns:
|
||||
Workflow results with analysis and posted insights
|
||||
"""
|
||||
logger.info(
|
||||
"Starting supplier performance analysis workflow",
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id,
|
||||
orders=len(order_history)
|
||||
)
|
||||
|
||||
# Step 1: Analyze supplier performance
|
||||
analysis_results = await self.predictor.analyze_supplier_performance(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id,
|
||||
order_history=order_history,
|
||||
min_orders=min_orders
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Supplier analysis complete",
|
||||
supplier_id=supplier_id,
|
||||
reliability_score=analysis_results.get('reliability_score'),
|
||||
insights_generated=len(analysis_results.get('insights', []))
|
||||
)
|
||||
|
||||
# Step 2: Enrich insights with tenant_id and supplier context
|
||||
enriched_insights = self._enrich_insights(
|
||||
analysis_results.get('insights', []),
|
||||
tenant_id,
|
||||
supplier_id
|
||||
)
|
||||
|
||||
# Step 3: Post insights to AI Insights Service
|
||||
if enriched_insights:
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_insights
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Supplier insights posted to AI Insights Service",
|
||||
supplier_id=supplier_id,
|
||||
total=post_results['total'],
|
||||
successful=post_results['successful'],
|
||||
failed=post_results['failed']
|
||||
)
|
||||
else:
|
||||
post_results = {'total': 0, 'successful': 0, 'failed': 0}
|
||||
logger.info("No insights to post for supplier", supplier_id=supplier_id)
|
||||
|
||||
# Step 4: Return comprehensive results
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'supplier_id': supplier_id,
|
||||
'analyzed_at': analysis_results['analyzed_at'],
|
||||
'orders_analyzed': analysis_results['orders_analyzed'],
|
||||
'reliability_score': analysis_results.get('reliability_score'),
|
||||
'risk_assessment': analysis_results.get('risk_assessment', {}),
|
||||
'predictions': analysis_results.get('predictions', {}),
|
||||
'insights_generated': len(enriched_insights),
|
||||
'insights_posted': post_results['successful'],
|
||||
'insights_failed': post_results['failed'],
|
||||
'created_insights': post_results.get('created_insights', [])
|
||||
}
|
||||
|
||||
def _enrich_insights(
|
||||
self,
|
||||
insights: List[Dict[str, Any]],
|
||||
tenant_id: str,
|
||||
supplier_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Enrich insights with required fields for AI Insights Service.
|
||||
|
||||
Args:
|
||||
insights: Raw insights from predictor
|
||||
tenant_id: Tenant identifier
|
||||
supplier_id: Supplier identifier
|
||||
|
||||
Returns:
|
||||
Enriched insights ready for posting
|
||||
"""
|
||||
enriched = []
|
||||
|
||||
for insight in insights:
|
||||
# Add required tenant_id
|
||||
enriched_insight = insight.copy()
|
||||
enriched_insight['tenant_id'] = tenant_id
|
||||
|
||||
# Add supplier context to metrics
|
||||
if 'metrics_json' not in enriched_insight:
|
||||
enriched_insight['metrics_json'] = {}
|
||||
|
||||
enriched_insight['metrics_json']['supplier_id'] = supplier_id
|
||||
|
||||
# Add source metadata
|
||||
enriched_insight['source_service'] = 'procurement'
|
||||
enriched_insight['source_model'] = 'supplier_performance_predictor'
|
||||
enriched_insight['detected_at'] = datetime.utcnow().isoformat()
|
||||
|
||||
enriched.append(enriched_insight)
|
||||
|
||||
return enriched
|
||||
|
||||
async def analyze_all_suppliers(
|
||||
self,
|
||||
tenant_id: str,
|
||||
suppliers_data: Dict[str, pd.DataFrame],
|
||||
min_orders: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze all suppliers for a tenant and generate comparative insights.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
suppliers_data: Dict of {supplier_id: order_history DataFrame}
|
||||
min_orders: Minimum orders for analysis
|
||||
|
||||
Returns:
|
||||
Comprehensive analysis with supplier comparison
|
||||
"""
|
||||
logger.info(
|
||||
"Analyzing all suppliers for tenant",
|
||||
tenant_id=tenant_id,
|
||||
suppliers=len(suppliers_data)
|
||||
)
|
||||
|
||||
all_results = []
|
||||
total_insights_posted = 0
|
||||
|
||||
# Analyze each supplier
|
||||
for supplier_id, order_history in suppliers_data.items():
|
||||
try:
|
||||
results = await self.analyze_and_post_supplier_insights(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id,
|
||||
order_history=order_history,
|
||||
min_orders=min_orders
|
||||
)
|
||||
|
||||
all_results.append(results)
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error analyzing supplier",
|
||||
supplier_id=supplier_id,
|
||||
error=str(e)
|
||||
)
|
||||
|
||||
# Compare suppliers
|
||||
comparison = self.predictor.compare_suppliers(
|
||||
[r for r in all_results if r.get('reliability_score') is not None]
|
||||
)
|
||||
|
||||
# Generate comparative insights if needed
|
||||
comparative_insights = self._generate_comparative_insights(
|
||||
tenant_id, comparison
|
||||
)
|
||||
|
||||
if comparative_insights:
|
||||
enriched_comparative = self._enrich_insights(
|
||||
comparative_insights, tenant_id, 'all_suppliers'
|
||||
)
|
||||
|
||||
post_results = await self.ai_insights_client.create_insights_bulk(
|
||||
tenant_id=UUID(tenant_id),
|
||||
insights=enriched_comparative
|
||||
)
|
||||
|
||||
total_insights_posted += post_results['successful']
|
||||
|
||||
logger.info(
|
||||
"All suppliers analysis complete",
|
||||
tenant_id=tenant_id,
|
||||
suppliers_analyzed=len(all_results),
|
||||
total_insights_posted=total_insights_posted
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'analyzed_at': datetime.utcnow().isoformat(),
|
||||
'suppliers_analyzed': len(all_results),
|
||||
'supplier_results': all_results,
|
||||
'comparison': comparison,
|
||||
'total_insights_posted': total_insights_posted
|
||||
}
|
||||
|
||||
def _generate_comparative_insights(
|
||||
self,
|
||||
tenant_id: str,
|
||||
comparison: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate insights from supplier comparison.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
comparison: Supplier comparison results
|
||||
|
||||
Returns:
|
||||
List of comparative insights
|
||||
"""
|
||||
insights = []
|
||||
|
||||
if 'recommendations' in comparison and comparison['recommendations']:
|
||||
for rec in comparison['recommendations']:
|
||||
if 'URGENT' in rec['recommendation']:
|
||||
priority = 'critical'
|
||||
elif 'high-risk' in rec.get('reason', '').lower():
|
||||
priority = 'high'
|
||||
else:
|
||||
priority = 'medium'
|
||||
|
||||
insights.append({
|
||||
'type': 'recommendation',
|
||||
'priority': priority,
|
||||
'category': 'procurement',
|
||||
'title': 'Supplier Comparison: Action Required',
|
||||
'description': rec['recommendation'],
|
||||
'impact_type': 'cost_optimization',
|
||||
'impact_value': 0,
|
||||
'impact_unit': 'recommendation',
|
||||
'confidence': 85,
|
||||
'metrics_json': {
|
||||
'comparison_type': 'multi_supplier',
|
||||
'suppliers_compared': comparison['suppliers_compared'],
|
||||
'top_supplier': comparison.get('top_supplier'),
|
||||
'top_score': comparison.get('top_supplier_score'),
|
||||
'reason': rec.get('reason', '')
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Review Supplier Portfolio',
|
||||
'action': 'review_supplier_portfolio',
|
||||
'params': {'tenant_id': tenant_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'supplier_performance_predictor'
|
||||
})
|
||||
|
||||
return insights
|
||||
|
||||
async def get_supplier_risk_score(
|
||||
self,
|
||||
supplier_id: str
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Get cached reliability score for a supplier.
|
||||
|
||||
Args:
|
||||
supplier_id: Supplier identifier
|
||||
|
||||
Returns:
|
||||
Reliability score (0-100) or None if not analyzed
|
||||
"""
|
||||
return self.predictor.get_supplier_reliability_score(supplier_id)
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client connections."""
|
||||
await self.ai_insights_client.close()
|
||||
701
services/procurement/app/ml/supplier_performance_predictor.py
Normal file
701
services/procurement/app/ml/supplier_performance_predictor.py
Normal file
@@ -0,0 +1,701 @@
|
||||
"""
|
||||
Supplier Performance Predictor
|
||||
Predicts supplier reliability, delivery delays, and quality issues
|
||||
Generates insights for procurement risk management
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import structlog
|
||||
from datetime import datetime, timedelta
|
||||
from collections import defaultdict
|
||||
from sklearn.ensemble import GradientBoostingClassifier, GradientBoostingRegressor
|
||||
from sklearn.preprocessing import StandardScaler
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class SupplierPerformancePredictor:
|
||||
"""
|
||||
Predicts supplier performance metrics for procurement risk management.
|
||||
|
||||
Capabilities:
|
||||
1. Delivery delay probability prediction
|
||||
2. Quality issue likelihood scoring
|
||||
3. Supplier reliability scoring (0-100)
|
||||
4. Alternative supplier recommendations
|
||||
5. Procurement risk assessment
|
||||
6. Insight generation for high-risk suppliers
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.delay_model = None
|
||||
self.quality_model = None
|
||||
self.reliability_scores = {}
|
||||
self.scaler = StandardScaler()
|
||||
self.feature_columns = []
|
||||
|
||||
async def analyze_supplier_performance(
|
||||
self,
|
||||
tenant_id: str,
|
||||
supplier_id: str,
|
||||
order_history: pd.DataFrame,
|
||||
min_orders: int = 10
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Analyze historical supplier performance and generate insights.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant identifier
|
||||
supplier_id: Supplier identifier
|
||||
order_history: Historical orders with columns:
|
||||
- order_date
|
||||
- expected_delivery_date
|
||||
- actual_delivery_date
|
||||
- order_quantity
|
||||
- received_quantity
|
||||
- quality_issues (bool)
|
||||
- quality_score (0-100)
|
||||
- order_value
|
||||
min_orders: Minimum orders required for analysis
|
||||
|
||||
Returns:
|
||||
Dictionary with performance metrics and insights
|
||||
"""
|
||||
logger.info(
|
||||
"Analyzing supplier performance",
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id,
|
||||
orders=len(order_history)
|
||||
)
|
||||
|
||||
if len(order_history) < min_orders:
|
||||
logger.warning(
|
||||
"Insufficient order history",
|
||||
supplier_id=supplier_id,
|
||||
orders=len(order_history),
|
||||
required=min_orders
|
||||
)
|
||||
return self._insufficient_data_response(tenant_id, supplier_id)
|
||||
|
||||
# Calculate performance metrics
|
||||
metrics = self._calculate_performance_metrics(order_history)
|
||||
|
||||
# Calculate reliability score
|
||||
reliability_score = self._calculate_reliability_score(metrics)
|
||||
|
||||
# Predict future performance
|
||||
predictions = self._predict_future_performance(order_history, metrics)
|
||||
|
||||
# Assess procurement risk
|
||||
risk_assessment = self._assess_procurement_risk(
|
||||
metrics, reliability_score, predictions
|
||||
)
|
||||
|
||||
# Generate insights
|
||||
insights = self._generate_supplier_insights(
|
||||
tenant_id, supplier_id, metrics, reliability_score,
|
||||
risk_assessment, predictions
|
||||
)
|
||||
|
||||
# Store reliability score
|
||||
self.reliability_scores[supplier_id] = reliability_score
|
||||
|
||||
logger.info(
|
||||
"Supplier performance analysis complete",
|
||||
supplier_id=supplier_id,
|
||||
reliability_score=reliability_score,
|
||||
insights_generated=len(insights)
|
||||
)
|
||||
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'supplier_id': supplier_id,
|
||||
'analyzed_at': datetime.utcnow().isoformat(),
|
||||
'orders_analyzed': len(order_history),
|
||||
'metrics': metrics,
|
||||
'reliability_score': reliability_score,
|
||||
'predictions': predictions,
|
||||
'risk_assessment': risk_assessment,
|
||||
'insights': insights
|
||||
}
|
||||
|
||||
def _calculate_performance_metrics(
|
||||
self,
|
||||
order_history: pd.DataFrame
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Calculate comprehensive supplier performance metrics.
|
||||
|
||||
Args:
|
||||
order_history: Historical order data
|
||||
|
||||
Returns:
|
||||
Dictionary of performance metrics
|
||||
"""
|
||||
# Ensure datetime columns
|
||||
order_history['order_date'] = pd.to_datetime(order_history['order_date'])
|
||||
order_history['expected_delivery_date'] = pd.to_datetime(order_history['expected_delivery_date'])
|
||||
order_history['actual_delivery_date'] = pd.to_datetime(order_history['actual_delivery_date'])
|
||||
|
||||
# Calculate delivery delays
|
||||
order_history['delivery_delay_days'] = (
|
||||
order_history['actual_delivery_date'] - order_history['expected_delivery_date']
|
||||
).dt.days
|
||||
|
||||
order_history['is_delayed'] = order_history['delivery_delay_days'] > 0
|
||||
order_history['is_early'] = order_history['delivery_delay_days'] < 0
|
||||
|
||||
# Calculate quantity accuracy
|
||||
order_history['quantity_accuracy'] = (
|
||||
order_history['received_quantity'] / order_history['order_quantity']
|
||||
)
|
||||
|
||||
order_history['is_short_delivery'] = order_history['quantity_accuracy'] < 1.0
|
||||
order_history['is_over_delivery'] = order_history['quantity_accuracy'] > 1.0
|
||||
|
||||
metrics = {
|
||||
# Delivery metrics
|
||||
'total_orders': int(len(order_history)),
|
||||
'on_time_orders': int((~order_history['is_delayed']).sum()),
|
||||
'delayed_orders': int(order_history['is_delayed'].sum()),
|
||||
'on_time_rate': float((~order_history['is_delayed']).mean() * 100),
|
||||
'avg_delivery_delay_days': float(order_history[order_history['is_delayed']]['delivery_delay_days'].mean()) if order_history['is_delayed'].any() else 0.0,
|
||||
'max_delivery_delay_days': int(order_history['delivery_delay_days'].max()),
|
||||
'delivery_delay_std': float(order_history['delivery_delay_days'].std()),
|
||||
|
||||
# Quantity accuracy metrics
|
||||
'avg_quantity_accuracy': float(order_history['quantity_accuracy'].mean() * 100),
|
||||
'short_deliveries': int(order_history['is_short_delivery'].sum()),
|
||||
'short_delivery_rate': float(order_history['is_short_delivery'].mean() * 100),
|
||||
|
||||
# Quality metrics
|
||||
'quality_issues': int(order_history['quality_issues'].sum()) if 'quality_issues' in order_history.columns else 0,
|
||||
'quality_issue_rate': float(order_history['quality_issues'].mean() * 100) if 'quality_issues' in order_history.columns else 0.0,
|
||||
'avg_quality_score': float(order_history['quality_score'].mean()) if 'quality_score' in order_history.columns else 100.0,
|
||||
|
||||
# Consistency metrics
|
||||
'delivery_consistency': float(100 - order_history['delivery_delay_days'].std() * 10), # Lower variance = higher consistency
|
||||
'quantity_consistency': float(100 - (order_history['quantity_accuracy'].std() * 100)),
|
||||
|
||||
# Recent trend (last 30 days vs overall)
|
||||
'recent_on_time_rate': self._calculate_recent_trend(order_history, 'is_delayed', days=30),
|
||||
|
||||
# Cost metrics
|
||||
'total_order_value': float(order_history['order_value'].sum()) if 'order_value' in order_history.columns else 0.0,
|
||||
'avg_order_value': float(order_history['order_value'].mean()) if 'order_value' in order_history.columns else 0.0
|
||||
}
|
||||
|
||||
# Ensure all metrics are valid (no NaN)
|
||||
for key, value in metrics.items():
|
||||
if isinstance(value, float) and np.isnan(value):
|
||||
metrics[key] = 0.0
|
||||
|
||||
return metrics
|
||||
|
||||
def _calculate_recent_trend(
|
||||
self,
|
||||
order_history: pd.DataFrame,
|
||||
metric_column: str,
|
||||
days: int = 30
|
||||
) -> float:
|
||||
"""Calculate recent trend for a metric."""
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
recent_orders = order_history[order_history['order_date'] >= cutoff_date]
|
||||
|
||||
if len(recent_orders) < 3:
|
||||
return 0.0 # Not enough recent data
|
||||
|
||||
if metric_column == 'is_delayed':
|
||||
return float((~recent_orders['is_delayed']).mean() * 100)
|
||||
else:
|
||||
return float(recent_orders[metric_column].mean() * 100)
|
||||
|
||||
def _calculate_reliability_score(
|
||||
self,
|
||||
metrics: Dict[str, Any]
|
||||
) -> int:
|
||||
"""
|
||||
Calculate overall supplier reliability score (0-100).
|
||||
|
||||
Factors:
|
||||
- On-time delivery rate (40%)
|
||||
- Quantity accuracy (20%)
|
||||
- Quality score (25%)
|
||||
- Consistency (15%)
|
||||
"""
|
||||
# On-time delivery score (40 points)
|
||||
on_time_score = metrics['on_time_rate'] * 0.40
|
||||
|
||||
# Quantity accuracy score (20 points)
|
||||
quantity_score = min(100, metrics['avg_quantity_accuracy']) * 0.20
|
||||
|
||||
# Quality score (25 points)
|
||||
quality_score = metrics['avg_quality_score'] * 0.25
|
||||
|
||||
# Consistency score (15 points)
|
||||
# Average of delivery and quantity consistency
|
||||
consistency_score = (
|
||||
(metrics['delivery_consistency'] + metrics['quantity_consistency']) / 2
|
||||
) * 0.15
|
||||
|
||||
total_score = on_time_score + quantity_score + quality_score + consistency_score
|
||||
|
||||
# Penalties
|
||||
# Severe penalty for high quality issue rate
|
||||
if metrics['quality_issue_rate'] > 10:
|
||||
total_score *= 0.8 # 20% penalty
|
||||
|
||||
# Penalty for high short delivery rate
|
||||
if metrics['short_delivery_rate'] > 15:
|
||||
total_score *= 0.9 # 10% penalty
|
||||
|
||||
return int(round(max(0, min(100, total_score))))
|
||||
|
||||
def _predict_future_performance(
|
||||
self,
|
||||
order_history: pd.DataFrame,
|
||||
metrics: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Predict future supplier performance based on trends.
|
||||
|
||||
Args:
|
||||
order_history: Historical order data
|
||||
metrics: Calculated performance metrics
|
||||
|
||||
Returns:
|
||||
Dictionary of predictions
|
||||
"""
|
||||
# Simple trend-based predictions
|
||||
# For production, could use ML models trained on multi-supplier data
|
||||
|
||||
predictions = {
|
||||
'next_order_delay_probability': 0.0,
|
||||
'next_order_quality_issue_probability': 0.0,
|
||||
'predicted_delivery_days': 0,
|
||||
'confidence': 0
|
||||
}
|
||||
|
||||
# Delay probability based on historical rate and recent trend
|
||||
historical_delay_rate = metrics['delayed_orders'] / max(1, metrics['total_orders'])
|
||||
recent_on_time_rate = metrics['recent_on_time_rate'] / 100
|
||||
|
||||
# Weight recent performance higher
|
||||
predicted_on_time_prob = (historical_delay_rate * 0.3) + ((1 - recent_on_time_rate) * 0.7)
|
||||
predictions['next_order_delay_probability'] = float(min(1.0, max(0.0, predicted_on_time_prob)))
|
||||
|
||||
# Quality issue probability
|
||||
if metrics['quality_issues'] > 0:
|
||||
quality_issue_prob = metrics['quality_issue_rate'] / 100
|
||||
predictions['next_order_quality_issue_probability'] = float(quality_issue_prob)
|
||||
|
||||
# Predicted delivery days (expected delay)
|
||||
if metrics['avg_delivery_delay_days'] > 0:
|
||||
predictions['predicted_delivery_days'] = int(round(metrics['avg_delivery_delay_days']))
|
||||
|
||||
# Confidence based on data quantity and recency
|
||||
if metrics['total_orders'] >= 50:
|
||||
predictions['confidence'] = 90
|
||||
elif metrics['total_orders'] >= 30:
|
||||
predictions['confidence'] = 80
|
||||
elif metrics['total_orders'] >= 20:
|
||||
predictions['confidence'] = 70
|
||||
else:
|
||||
predictions['confidence'] = 60
|
||||
|
||||
return predictions
|
||||
|
||||
def _assess_procurement_risk(
|
||||
self,
|
||||
metrics: Dict[str, Any],
|
||||
reliability_score: int,
|
||||
predictions: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Assess overall procurement risk for this supplier.
|
||||
|
||||
Risk levels: low, medium, high, critical
|
||||
"""
|
||||
risk_factors = []
|
||||
risk_score = 0 # 0-100, higher = more risky
|
||||
|
||||
# Low reliability
|
||||
if reliability_score < 60:
|
||||
risk_factors.append('Low reliability score')
|
||||
risk_score += 30
|
||||
elif reliability_score < 75:
|
||||
risk_factors.append('Medium reliability score')
|
||||
risk_score += 15
|
||||
|
||||
# High delay probability
|
||||
if predictions['next_order_delay_probability'] > 0.5:
|
||||
risk_factors.append('High delay probability')
|
||||
risk_score += 25
|
||||
elif predictions['next_order_delay_probability'] > 0.3:
|
||||
risk_factors.append('Moderate delay probability')
|
||||
risk_score += 15
|
||||
|
||||
# Quality issues
|
||||
if metrics['quality_issue_rate'] > 15:
|
||||
risk_factors.append('High quality issue rate')
|
||||
risk_score += 25
|
||||
elif metrics['quality_issue_rate'] > 5:
|
||||
risk_factors.append('Moderate quality issue rate')
|
||||
risk_score += 10
|
||||
|
||||
# Quantity accuracy issues
|
||||
if metrics['short_delivery_rate'] > 20:
|
||||
risk_factors.append('Frequent short deliveries')
|
||||
risk_score += 15
|
||||
elif metrics['short_delivery_rate'] > 10:
|
||||
risk_factors.append('Occasional short deliveries')
|
||||
risk_score += 8
|
||||
|
||||
# Low consistency
|
||||
if metrics['delivery_consistency'] < 60:
|
||||
risk_factors.append('Inconsistent delivery timing')
|
||||
risk_score += 10
|
||||
|
||||
# Determine risk level
|
||||
if risk_score >= 70:
|
||||
risk_level = 'critical'
|
||||
elif risk_score >= 50:
|
||||
risk_level = 'high'
|
||||
elif risk_score >= 30:
|
||||
risk_level = 'medium'
|
||||
else:
|
||||
risk_level = 'low'
|
||||
|
||||
return {
|
||||
'risk_level': risk_level,
|
||||
'risk_score': min(100, risk_score),
|
||||
'risk_factors': risk_factors,
|
||||
'recommendation': self._get_risk_recommendation(risk_level, risk_factors)
|
||||
}
|
||||
|
||||
def _get_risk_recommendation(
|
||||
self,
|
||||
risk_level: str,
|
||||
risk_factors: List[str]
|
||||
) -> str:
|
||||
"""Generate risk mitigation recommendation."""
|
||||
if risk_level == 'critical':
|
||||
return 'URGENT: Consider switching to alternative supplier. Current supplier poses significant operational risk.'
|
||||
elif risk_level == 'high':
|
||||
return 'HIGH PRIORITY: Increase safety stock and have backup supplier ready. Monitor closely.'
|
||||
elif risk_level == 'medium':
|
||||
return 'MONITOR: Keep standard safety stock. Review performance quarterly.'
|
||||
else:
|
||||
return 'LOW RISK: Supplier performing well. Maintain current relationship.'
|
||||
|
||||
def _generate_supplier_insights(
|
||||
self,
|
||||
tenant_id: str,
|
||||
supplier_id: str,
|
||||
metrics: Dict[str, Any],
|
||||
reliability_score: int,
|
||||
risk_assessment: Dict[str, Any],
|
||||
predictions: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate actionable insights for procurement team.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
supplier_id: Supplier ID
|
||||
metrics: Performance metrics
|
||||
reliability_score: Overall reliability (0-100)
|
||||
risk_assessment: Risk assessment results
|
||||
predictions: Future performance predictions
|
||||
|
||||
Returns:
|
||||
List of insight dictionaries
|
||||
"""
|
||||
insights = []
|
||||
|
||||
# Insight 1: Low reliability alert
|
||||
if reliability_score < 60:
|
||||
insights.append({
|
||||
'type': 'alert',
|
||||
'priority': 'critical' if reliability_score < 50 else 'high',
|
||||
'category': 'procurement',
|
||||
'title': f'Low Supplier Reliability: {reliability_score}/100',
|
||||
'description': f'Supplier {supplier_id} has low reliability score of {reliability_score}. On-time rate: {metrics["on_time_rate"]:.1f}%, Quality: {metrics["avg_quality_score"]:.1f}. Consider alternative suppliers.',
|
||||
'impact_type': 'operational_risk',
|
||||
'impact_value': 100 - reliability_score,
|
||||
'impact_unit': 'risk_points',
|
||||
'confidence': 85,
|
||||
'metrics_json': {
|
||||
'supplier_id': supplier_id,
|
||||
'reliability_score': reliability_score,
|
||||
'on_time_rate': round(metrics['on_time_rate'], 2),
|
||||
'quality_score': round(metrics['avg_quality_score'], 2),
|
||||
'quality_issue_rate': round(metrics['quality_issue_rate'], 2),
|
||||
'delayed_orders': metrics['delayed_orders'],
|
||||
'total_orders': metrics['total_orders']
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Find Alternative Supplier',
|
||||
'action': 'search_alternative_suppliers',
|
||||
'params': {'current_supplier_id': supplier_id}
|
||||
},
|
||||
{
|
||||
'label': 'Increase Safety Stock',
|
||||
'action': 'adjust_safety_stock',
|
||||
'params': {'supplier_id': supplier_id, 'multiplier': 1.5}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'supplier_performance_predictor'
|
||||
})
|
||||
|
||||
# Insight 2: High delay probability
|
||||
if predictions['next_order_delay_probability'] > 0.4:
|
||||
delay_prob_pct = predictions['next_order_delay_probability'] * 100
|
||||
insights.append({
|
||||
'type': 'prediction',
|
||||
'priority': 'high' if delay_prob_pct > 60 else 'medium',
|
||||
'category': 'procurement',
|
||||
'title': f'High Delay Risk: {delay_prob_pct:.0f}% Probability',
|
||||
'description': f'Supplier {supplier_id} has {delay_prob_pct:.0f}% probability of delaying next order. Expected delay: {predictions["predicted_delivery_days"]} days. Plan accordingly.',
|
||||
'impact_type': 'operational_risk',
|
||||
'impact_value': delay_prob_pct,
|
||||
'impact_unit': 'probability_percent',
|
||||
'confidence': predictions['confidence'],
|
||||
'metrics_json': {
|
||||
'supplier_id': supplier_id,
|
||||
'delay_probability': round(delay_prob_pct, 2),
|
||||
'predicted_delay_days': predictions['predicted_delivery_days'],
|
||||
'historical_delay_rate': round(metrics['delayed_orders'] / max(1, metrics['total_orders']) * 100, 2),
|
||||
'avg_delay_days': round(metrics['avg_delivery_delay_days'], 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Order Earlier',
|
||||
'action': 'adjust_order_lead_time',
|
||||
'params': {
|
||||
'supplier_id': supplier_id,
|
||||
'additional_days': predictions['predicted_delivery_days'] + 2
|
||||
}
|
||||
},
|
||||
{
|
||||
'label': 'Increase Safety Stock',
|
||||
'action': 'adjust_safety_stock',
|
||||
'params': {'supplier_id': supplier_id, 'multiplier': 1.3}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'supplier_performance_predictor'
|
||||
})
|
||||
|
||||
# Insight 3: Quality issues
|
||||
if metrics['quality_issue_rate'] > 10:
|
||||
insights.append({
|
||||
'type': 'alert',
|
||||
'priority': 'high',
|
||||
'category': 'procurement',
|
||||
'title': f'Quality Issues: {metrics["quality_issue_rate"]:.1f}% of Orders',
|
||||
'description': f'Supplier {supplier_id} has quality issues in {metrics["quality_issue_rate"]:.1f}% of orders ({metrics["quality_issues"]} of {metrics["total_orders"]}). This impacts product quality and customer satisfaction.',
|
||||
'impact_type': 'quality_risk',
|
||||
'impact_value': metrics['quality_issue_rate'],
|
||||
'impact_unit': 'percentage',
|
||||
'confidence': 90,
|
||||
'metrics_json': {
|
||||
'supplier_id': supplier_id,
|
||||
'quality_issue_rate': round(metrics['quality_issue_rate'], 2),
|
||||
'quality_issues': metrics['quality_issues'],
|
||||
'avg_quality_score': round(metrics['avg_quality_score'], 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Review Supplier Quality',
|
||||
'action': 'schedule_supplier_review',
|
||||
'params': {'supplier_id': supplier_id, 'reason': 'quality_issues'}
|
||||
},
|
||||
{
|
||||
'label': 'Increase Inspection',
|
||||
'action': 'increase_quality_checks',
|
||||
'params': {'supplier_id': supplier_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'supplier_performance_predictor'
|
||||
})
|
||||
|
||||
# Insight 4: Excellent performance (positive insight)
|
||||
if reliability_score >= 90:
|
||||
insights.append({
|
||||
'type': 'insight',
|
||||
'priority': 'low',
|
||||
'category': 'procurement',
|
||||
'title': f'Excellent Supplier Performance: {reliability_score}/100',
|
||||
'description': f'Supplier {supplier_id} demonstrates excellent performance with {reliability_score} reliability score. On-time: {metrics["on_time_rate"]:.1f}%, Quality: {metrics["avg_quality_score"]:.1f}. Consider expanding partnership.',
|
||||
'impact_type': 'positive_performance',
|
||||
'impact_value': reliability_score,
|
||||
'impact_unit': 'score',
|
||||
'confidence': 90,
|
||||
'metrics_json': {
|
||||
'supplier_id': supplier_id,
|
||||
'reliability_score': reliability_score,
|
||||
'on_time_rate': round(metrics['on_time_rate'], 2),
|
||||
'quality_score': round(metrics['avg_quality_score'], 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Increase Order Volume',
|
||||
'action': 'adjust_supplier_allocation',
|
||||
'params': {'supplier_id': supplier_id, 'increase_pct': 20}
|
||||
},
|
||||
{
|
||||
'label': 'Negotiate Better Terms',
|
||||
'action': 'initiate_negotiation',
|
||||
'params': {'supplier_id': supplier_id, 'reason': 'volume_increase'}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'supplier_performance_predictor'
|
||||
})
|
||||
|
||||
# Insight 5: Performance decline
|
||||
if metrics['recent_on_time_rate'] > 0 and metrics['recent_on_time_rate'] < metrics['on_time_rate'] - 15:
|
||||
insights.append({
|
||||
'type': 'alert',
|
||||
'priority': 'medium',
|
||||
'category': 'procurement',
|
||||
'title': 'Supplier Performance Decline Detected',
|
||||
'description': f'Supplier {supplier_id} recent performance ({metrics["recent_on_time_rate"]:.1f}% on-time) is significantly worse than historical average ({metrics["on_time_rate"]:.1f}%). Investigate potential issues.',
|
||||
'impact_type': 'performance_decline',
|
||||
'impact_value': metrics['on_time_rate'] - metrics['recent_on_time_rate'],
|
||||
'impact_unit': 'percentage_points',
|
||||
'confidence': 75,
|
||||
'metrics_json': {
|
||||
'supplier_id': supplier_id,
|
||||
'recent_on_time_rate': round(metrics['recent_on_time_rate'], 2),
|
||||
'historical_on_time_rate': round(metrics['on_time_rate'], 2),
|
||||
'decline': round(metrics['on_time_rate'] - metrics['recent_on_time_rate'], 2)
|
||||
},
|
||||
'actionable': True,
|
||||
'recommendation_actions': [
|
||||
{
|
||||
'label': 'Contact Supplier',
|
||||
'action': 'schedule_supplier_meeting',
|
||||
'params': {'supplier_id': supplier_id, 'reason': 'performance_decline'}
|
||||
},
|
||||
{
|
||||
'label': 'Monitor Closely',
|
||||
'action': 'increase_monitoring_frequency',
|
||||
'params': {'supplier_id': supplier_id}
|
||||
}
|
||||
],
|
||||
'source_service': 'procurement',
|
||||
'source_model': 'supplier_performance_predictor'
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"Generated supplier insights",
|
||||
supplier_id=supplier_id,
|
||||
insights=len(insights)
|
||||
)
|
||||
|
||||
return insights
|
||||
|
||||
def _insufficient_data_response(
|
||||
self,
|
||||
tenant_id: str,
|
||||
supplier_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Return response when insufficient data available."""
|
||||
return {
|
||||
'tenant_id': tenant_id,
|
||||
'supplier_id': supplier_id,
|
||||
'analyzed_at': datetime.utcnow().isoformat(),
|
||||
'orders_analyzed': 0,
|
||||
'metrics': {},
|
||||
'reliability_score': None,
|
||||
'predictions': {},
|
||||
'risk_assessment': {
|
||||
'risk_level': 'unknown',
|
||||
'risk_score': None,
|
||||
'risk_factors': ['Insufficient historical data'],
|
||||
'recommendation': 'Collect more order history before assessing supplier performance.'
|
||||
},
|
||||
'insights': []
|
||||
}
|
||||
|
||||
def compare_suppliers(
|
||||
self,
|
||||
suppliers_analysis: List[Dict[str, Any]],
|
||||
product_category: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare multiple suppliers and provide recommendations.
|
||||
|
||||
Args:
|
||||
suppliers_analysis: List of supplier analysis results
|
||||
product_category: Optional product category filter
|
||||
|
||||
Returns:
|
||||
Comparison report with recommendations
|
||||
"""
|
||||
if not suppliers_analysis:
|
||||
return {'error': 'No suppliers to compare'}
|
||||
|
||||
# Sort by reliability score
|
||||
ranked_suppliers = sorted(
|
||||
suppliers_analysis,
|
||||
key=lambda x: x.get('reliability_score', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
comparison = {
|
||||
'analyzed_at': datetime.utcnow().isoformat(),
|
||||
'suppliers_compared': len(ranked_suppliers),
|
||||
'product_category': product_category,
|
||||
'top_supplier': ranked_suppliers[0]['supplier_id'],
|
||||
'top_supplier_score': ranked_suppliers[0]['reliability_score'],
|
||||
'bottom_supplier': ranked_suppliers[-1]['supplier_id'],
|
||||
'bottom_supplier_score': ranked_suppliers[-1]['reliability_score'],
|
||||
'ranked_suppliers': [
|
||||
{
|
||||
'supplier_id': s['supplier_id'],
|
||||
'reliability_score': s['reliability_score'],
|
||||
'risk_level': s['risk_assessment']['risk_level']
|
||||
}
|
||||
for s in ranked_suppliers
|
||||
],
|
||||
'recommendations': []
|
||||
}
|
||||
|
||||
# Generate comparison insights
|
||||
if len(ranked_suppliers) >= 2:
|
||||
score_gap = ranked_suppliers[0]['reliability_score'] - ranked_suppliers[-1]['reliability_score']
|
||||
|
||||
if score_gap > 30:
|
||||
comparison['recommendations'].append({
|
||||
'recommendation': f'Consider consolidating orders with top supplier {ranked_suppliers[0]["supplier_id"]} (score: {ranked_suppliers[0]["reliability_score"]})',
|
||||
'reason': f'Significant performance gap ({score_gap} points) from lowest performer'
|
||||
})
|
||||
|
||||
# Check for high-risk suppliers
|
||||
high_risk = [s for s in ranked_suppliers if s['risk_assessment']['risk_level'] in ['high', 'critical']]
|
||||
if high_risk:
|
||||
comparison['recommendations'].append({
|
||||
'recommendation': f'URGENT: Replace {len(high_risk)} high-risk supplier(s)',
|
||||
'reason': 'Significant operational risk from unreliable suppliers',
|
||||
'affected_suppliers': [s['supplier_id'] for s in high_risk]
|
||||
})
|
||||
|
||||
return comparison
|
||||
|
||||
def get_supplier_reliability_score(self, supplier_id: str) -> Optional[int]:
|
||||
"""Get cached reliability score for a supplier."""
|
||||
return self.reliability_scores.get(supplier_id)
|
||||
Reference in New Issue
Block a user