Improve AI logic

This commit is contained in:
Urtzi Alfaro
2025-11-05 13:34:56 +01:00
parent 5c87fbcf48
commit 394ad3aea4
218 changed files with 30627 additions and 7658 deletions

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -31,6 +31,12 @@ prometheus-client==0.23.1
python-dateutil==2.9.0.post0
pytz==2024.2
# Data processing for ML insights
pandas==2.2.3
numpy==2.2.1
scikit-learn==1.6.1
scipy==1.15.1
# Validation and utilities
email-validator==2.2.0

View File

@@ -0,0 +1,481 @@
"""
Tests for Supplier Performance Predictor
"""
import pytest
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from app.ml.supplier_performance_predictor import SupplierPerformancePredictor
@pytest.fixture
def sample_order_history_good_supplier():
"""Generate sample order history for a reliable supplier."""
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='W')
orders = []
for i, date in enumerate(dates):
expected_delivery = date + timedelta(days=3)
# Good supplier: 95% on-time, occasional 1-day delay
if np.random.random() < 0.95:
actual_delivery = expected_delivery
else:
actual_delivery = expected_delivery + timedelta(days=1)
# Good quality: 98% no issues
quality_issues = np.random.random() > 0.98
quality_score = np.random.uniform(90, 100) if not quality_issues else np.random.uniform(70, 85)
# Good quantity accuracy: 99% accurate
quantity_accuracy = np.random.uniform(0.98, 1.02)
orders.append({
'order_id': f'order-{i}',
'order_date': date,
'expected_delivery_date': expected_delivery,
'actual_delivery_date': actual_delivery,
'order_quantity': 100,
'received_quantity': int(100 * quantity_accuracy),
'quality_issues': quality_issues,
'quality_score': quality_score,
'order_value': 500.0
})
return pd.DataFrame(orders)
@pytest.fixture
def sample_order_history_poor_supplier():
"""Generate sample order history for an unreliable supplier."""
dates = pd.date_range(start='2024-01-01', end='2024-12-31', freq='W')
orders = []
for i, date in enumerate(dates):
expected_delivery = date + timedelta(days=3)
# Poor supplier: 60% on-time, frequent delays of 2-5 days
if np.random.random() < 0.60:
actual_delivery = expected_delivery
else:
actual_delivery = expected_delivery + timedelta(days=np.random.randint(2, 6))
# Poor quality: 20% issues
quality_issues = np.random.random() > 0.80
quality_score = np.random.uniform(85, 100) if not quality_issues else np.random.uniform(50, 75)
# Poor quantity accuracy: frequent short deliveries
if np.random.random() < 0.25:
quantity_accuracy = np.random.uniform(0.75, 0.95) # Short delivery
else:
quantity_accuracy = np.random.uniform(0.95, 1.05)
orders.append({
'order_id': f'order-{i}',
'order_date': date,
'expected_delivery_date': expected_delivery,
'actual_delivery_date': actual_delivery,
'order_quantity': 100,
'received_quantity': int(100 * quantity_accuracy),
'quality_issues': quality_issues,
'quality_score': quality_score,
'order_value': 500.0
})
return pd.DataFrame(orders)
@pytest.mark.asyncio
async def test_analyze_good_supplier(sample_order_history_good_supplier):
"""Test analysis of a reliable supplier."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='good-supplier',
order_history=sample_order_history_good_supplier,
min_orders=10
)
# Check structure
assert 'tenant_id' in results
assert 'supplier_id' in results
assert 'reliability_score' in results
assert 'metrics' in results
assert 'predictions' in results
assert 'risk_assessment' in results
assert 'insights' in results
# Check metrics calculated
metrics = results['metrics']
assert metrics['total_orders'] == len(sample_order_history_good_supplier)
assert 'on_time_rate' in metrics
assert 'quality_issue_rate' in metrics
assert 'avg_quantity_accuracy' in metrics
# Good supplier should have high reliability score
reliability_score = results['reliability_score']
assert reliability_score >= 85, f"Expected high reliability, got {reliability_score}"
# Risk should be low
risk_assessment = results['risk_assessment']
assert risk_assessment['risk_level'] in ['low', 'medium']
@pytest.mark.asyncio
async def test_analyze_poor_supplier(sample_order_history_poor_supplier):
"""Test analysis of an unreliable supplier."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='poor-supplier',
order_history=sample_order_history_poor_supplier,
min_orders=10
)
# Poor supplier should have low reliability score
reliability_score = results['reliability_score']
assert reliability_score < 75, f"Expected low reliability, got {reliability_score}"
# Risk should be high or critical
risk_assessment = results['risk_assessment']
assert risk_assessment['risk_level'] in ['medium', 'high', 'critical']
# Should have risk factors
assert len(risk_assessment['risk_factors']) > 0
# Should generate insights
insights = results['insights']
assert len(insights) > 0
# Should have at least one alert or prediction
alert_insights = [i for i in insights if i['type'] in ['alert', 'prediction']]
assert len(alert_insights) > 0
@pytest.mark.asyncio
async def test_performance_metrics_calculation(sample_order_history_good_supplier):
"""Test detailed metrics calculation."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='test-supplier',
order_history=sample_order_history_good_supplier
)
metrics = results['metrics']
# Check all key metrics present
required_metrics = [
'total_orders',
'on_time_orders',
'delayed_orders',
'on_time_rate',
'avg_delivery_delay_days',
'avg_quantity_accuracy',
'short_deliveries',
'short_delivery_rate',
'quality_issues',
'quality_issue_rate',
'avg_quality_score',
'delivery_consistency',
'quantity_consistency'
]
for metric in required_metrics:
assert metric in metrics, f"Missing metric: {metric}"
# Check metrics are reasonable
assert 0 <= metrics['on_time_rate'] <= 100
assert 0 <= metrics['avg_quantity_accuracy'] <= 200 # Allow up to 200% over-delivery
assert 0 <= metrics['quality_issue_rate'] <= 100
assert 0 <= metrics['avg_quality_score'] <= 100
@pytest.mark.asyncio
async def test_reliability_score_calculation():
"""Test reliability score calculation with known inputs."""
predictor = SupplierPerformancePredictor()
# Perfect metrics
perfect_metrics = {
'on_time_rate': 100.0,
'avg_quantity_accuracy': 100.0,
'avg_quality_score': 100.0,
'delivery_consistency': 100.0,
'quantity_consistency': 100.0,
'quality_issue_rate': 0.0,
'short_delivery_rate': 0.0
}
perfect_score = predictor._calculate_reliability_score(perfect_metrics)
assert perfect_score >= 95, f"Expected perfect score ~100, got {perfect_score}"
# Poor metrics
poor_metrics = {
'on_time_rate': 50.0,
'avg_quantity_accuracy': 85.0,
'avg_quality_score': 70.0,
'delivery_consistency': 50.0,
'quantity_consistency': 60.0,
'quality_issue_rate': 20.0, # Should apply penalty
'short_delivery_rate': 25.0 # Should apply penalty
}
poor_score = predictor._calculate_reliability_score(poor_metrics)
assert poor_score < 70, f"Expected poor score <70, got {poor_score}"
@pytest.mark.asyncio
async def test_delay_probability_prediction(sample_order_history_poor_supplier):
"""Test delay probability prediction."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='test-supplier',
order_history=sample_order_history_poor_supplier
)
predictions = results['predictions']
# Should have delay probability
assert 'next_order_delay_probability' in predictions
assert 0 <= predictions['next_order_delay_probability'] <= 1.0
# Poor supplier should have higher delay probability
assert predictions['next_order_delay_probability'] > 0.3
# Should have confidence score
assert 'confidence' in predictions
assert 0 <= predictions['confidence'] <= 100
@pytest.mark.asyncio
async def test_risk_assessment(sample_order_history_poor_supplier):
"""Test procurement risk assessment."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='test-supplier',
order_history=sample_order_history_poor_supplier
)
risk_assessment = results['risk_assessment']
# Check structure
assert 'risk_level' in risk_assessment
assert 'risk_score' in risk_assessment
assert 'risk_factors' in risk_assessment
assert 'recommendation' in risk_assessment
# Risk level should be valid
assert risk_assessment['risk_level'] in ['low', 'medium', 'high', 'critical']
# Risk score should be 0-100
assert 0 <= risk_assessment['risk_score'] <= 100
# Should have risk factors for poor supplier
assert len(risk_assessment['risk_factors']) > 0
# Recommendation should be string
assert isinstance(risk_assessment['recommendation'], str)
assert len(risk_assessment['recommendation']) > 0
@pytest.mark.asyncio
async def test_insight_generation_low_reliability(sample_order_history_poor_supplier):
"""Test insight generation for low reliability supplier."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='poor-supplier',
order_history=sample_order_history_poor_supplier
)
insights = results['insights']
# Should generate insights
assert len(insights) > 0
# Check for low reliability alert
reliability_insights = [i for i in insights
if 'reliability' in i.get('title', '').lower()]
if reliability_insights:
insight = reliability_insights[0]
assert insight['type'] in ['alert', 'recommendation']
assert insight['priority'] in ['high', 'critical']
assert 'actionable' in insight
assert insight['actionable'] is True
assert 'recommendation_actions' in insight
assert len(insight['recommendation_actions']) > 0
@pytest.mark.asyncio
async def test_insight_generation_high_delay_risk(sample_order_history_poor_supplier):
"""Test insight generation for high delay probability."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='poor-supplier',
order_history=sample_order_history_poor_supplier
)
insights = results['insights']
# Check for delay risk prediction
delay_insights = [i for i in insights
if 'delay' in i.get('title', '').lower()]
if delay_insights:
insight = delay_insights[0]
assert 'confidence' in insight
assert 'metrics_json' in insight
assert 'recommendation_actions' in insight
@pytest.mark.asyncio
async def test_insight_generation_excellent_supplier(sample_order_history_good_supplier):
"""Test that excellent suppliers get positive insights."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='excellent-supplier',
order_history=sample_order_history_good_supplier
)
insights = results['insights']
# Should have positive insight for excellent performance
positive_insights = [i for i in insights
if 'excellent' in i.get('title', '').lower()]
if positive_insights:
insight = positive_insights[0]
assert insight['type'] == 'insight'
assert insight['impact_type'] == 'positive_performance'
def test_compare_suppliers():
"""Test supplier comparison functionality."""
predictor = SupplierPerformancePredictor()
# Mock analysis results
suppliers_analysis = [
{
'supplier_id': 'supplier-1',
'reliability_score': 95,
'risk_assessment': {'risk_level': 'low', 'risk_score': 10}
},
{
'supplier_id': 'supplier-2',
'reliability_score': 60,
'risk_assessment': {'risk_level': 'high', 'risk_score': 75}
},
{
'supplier_id': 'supplier-3',
'reliability_score': 80,
'risk_assessment': {'risk_level': 'medium', 'risk_score': 40}
}
]
comparison = predictor.compare_suppliers(suppliers_analysis)
# Check structure
assert 'suppliers_compared' in comparison
assert 'top_supplier' in comparison
assert 'top_supplier_score' in comparison
assert 'bottom_supplier' in comparison
assert 'bottom_supplier_score' in comparison
assert 'ranked_suppliers' in comparison
assert 'recommendations' in comparison
# Check ranking
assert comparison['suppliers_compared'] == 3
assert comparison['top_supplier'] == 'supplier-1'
assert comparison['top_supplier_score'] == 95
assert comparison['bottom_supplier'] == 'supplier-2'
assert comparison['bottom_supplier_score'] == 60
# Ranked suppliers should be in order
ranked = comparison['ranked_suppliers']
assert ranked[0]['supplier_id'] == 'supplier-1'
assert ranked[-1]['supplier_id'] == 'supplier-2'
# Should have recommendations
assert len(comparison['recommendations']) > 0
@pytest.mark.asyncio
async def test_insufficient_data_handling():
"""Test handling of insufficient order history."""
predictor = SupplierPerformancePredictor()
# Only 5 orders (less than min_orders=10)
small_history = pd.DataFrame([
{
'order_date': datetime(2024, 1, i),
'expected_delivery_date': datetime(2024, 1, i+3),
'actual_delivery_date': datetime(2024, 1, i+3),
'order_quantity': 100,
'received_quantity': 100,
'quality_issues': False,
'quality_score': 95.0,
'order_value': 500.0
}
for i in range(1, 6)
])
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='new-supplier',
order_history=small_history,
min_orders=10
)
# Should return insufficient data response
assert results['orders_analyzed'] == 0
assert results['reliability_score'] is None
assert results['risk_assessment']['risk_level'] == 'unknown'
assert 'Insufficient' in results['risk_assessment']['risk_factors'][0]
def test_get_supplier_reliability_score():
"""Test getting cached reliability scores."""
predictor = SupplierPerformancePredictor()
# Initially no score
assert predictor.get_supplier_reliability_score('supplier-1') is None
# Set a score
predictor.reliability_scores['supplier-1'] = 85
# Should retrieve it
assert predictor.get_supplier_reliability_score('supplier-1') == 85
@pytest.mark.asyncio
async def test_metrics_no_nan_values(sample_order_history_good_supplier):
"""Test that metrics never contain NaN values."""
predictor = SupplierPerformancePredictor()
results = await predictor.analyze_supplier_performance(
tenant_id='test-tenant',
supplier_id='test-supplier',
order_history=sample_order_history_good_supplier
)
metrics = results['metrics']
# Check no NaN values
for key, value in metrics.items():
if isinstance(value, float):
assert not np.isnan(value), f"Metric {key} is NaN"