863 lines
37 KiB
Python
863 lines
37 KiB
Python
# ================================================================
|
|
# services/suppliers/app/services/performance_service.py
|
|
# ================================================================
|
|
"""
|
|
Supplier Performance Tracking Service
|
|
Comprehensive supplier performance calculation, tracking, and analytics
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import List, Optional, Dict, Any, Tuple
|
|
from uuid import UUID
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, and_, or_, desc, asc
|
|
from sqlalchemy.orm import selectinload
|
|
import structlog
|
|
from decimal import Decimal
|
|
|
|
from app.models.suppliers import (
|
|
Supplier, PurchaseOrder, Delivery, SupplierQualityReview,
|
|
PurchaseOrderStatus, DeliveryStatus, QualityRating, DeliveryRating
|
|
)
|
|
from app.models.performance import (
|
|
SupplierPerformanceMetric, SupplierScorecard, SupplierAlert,
|
|
PerformanceMetricType, PerformancePeriod, AlertType, AlertSeverity,
|
|
AlertStatus
|
|
)
|
|
from app.schemas.performance import (
|
|
PerformanceMetricCreate, ScorecardCreate, AlertCreate,
|
|
PerformanceDashboardSummary, SupplierPerformanceInsights,
|
|
PerformanceAnalytics, BusinessModelInsights
|
|
)
|
|
from app.core.config import settings
|
|
from shared.database.transactions import transactional
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
|
class PerformanceTrackingService:
|
|
"""Service for tracking and calculating supplier performance metrics"""
|
|
|
|
def __init__(self):
|
|
self.logger = logger.bind(service="performance_tracking")
|
|
|
|
@transactional
|
|
async def calculate_supplier_performance(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier_id: UUID,
|
|
tenant_id: UUID,
|
|
period: PerformancePeriod,
|
|
period_start: datetime,
|
|
period_end: datetime
|
|
) -> SupplierPerformanceMetric:
|
|
"""Calculate comprehensive performance metrics for a supplier"""
|
|
try:
|
|
self.logger.info("Calculating supplier performance",
|
|
supplier_id=str(supplier_id),
|
|
period=period.value,
|
|
period_start=period_start.isoformat(),
|
|
period_end=period_end.isoformat())
|
|
|
|
# Get base data for calculations
|
|
orders_data = await self._get_orders_data(db, supplier_id, tenant_id, period_start, period_end)
|
|
deliveries_data = await self._get_deliveries_data(db, supplier_id, tenant_id, period_start, period_end)
|
|
quality_data = await self._get_quality_data(db, supplier_id, tenant_id, period_start, period_end)
|
|
|
|
# Calculate delivery performance
|
|
delivery_performance = await self._calculate_delivery_performance(
|
|
orders_data, deliveries_data
|
|
)
|
|
|
|
# Calculate quality performance
|
|
quality_performance = await self._calculate_quality_performance(
|
|
deliveries_data, quality_data
|
|
)
|
|
|
|
# Calculate cost performance
|
|
cost_performance = await self._calculate_cost_performance(
|
|
orders_data, deliveries_data
|
|
)
|
|
|
|
# Calculate service performance
|
|
service_performance = await self._calculate_service_performance(
|
|
orders_data, quality_data
|
|
)
|
|
|
|
# Calculate overall performance (weighted average)
|
|
overall_performance = (
|
|
delivery_performance * 0.30 +
|
|
quality_performance * 0.30 +
|
|
cost_performance * 0.20 +
|
|
service_performance * 0.20
|
|
)
|
|
|
|
# Create performance metrics for each category
|
|
performance_metrics = []
|
|
|
|
metrics_to_create = [
|
|
(PerformanceMetricType.DELIVERY_PERFORMANCE, delivery_performance),
|
|
(PerformanceMetricType.QUALITY_SCORE, quality_performance),
|
|
(PerformanceMetricType.PRICE_COMPETITIVENESS, cost_performance),
|
|
(PerformanceMetricType.COMMUNICATION_RATING, service_performance)
|
|
]
|
|
|
|
for metric_type, value in metrics_to_create:
|
|
# Get previous period value for trend calculation
|
|
previous_value = await self._get_previous_period_value(
|
|
db, supplier_id, tenant_id, metric_type, period, period_start
|
|
)
|
|
|
|
# Calculate trend
|
|
trend_direction, trend_percentage = self._calculate_trend(value, previous_value)
|
|
|
|
# Prepare detailed metrics data
|
|
metrics_data = await self._prepare_detailed_metrics(
|
|
metric_type, orders_data, deliveries_data, quality_data
|
|
)
|
|
|
|
# Create performance metric
|
|
metric_create = PerformanceMetricCreate(
|
|
supplier_id=supplier_id,
|
|
metric_type=metric_type,
|
|
period=period,
|
|
period_start=period_start,
|
|
period_end=period_end,
|
|
metric_value=value,
|
|
target_value=self._get_target_value(metric_type),
|
|
total_orders=orders_data.get('total_orders', 0),
|
|
total_deliveries=deliveries_data.get('total_deliveries', 0),
|
|
on_time_deliveries=deliveries_data.get('on_time_deliveries', 0),
|
|
late_deliveries=deliveries_data.get('late_deliveries', 0),
|
|
quality_issues=quality_data.get('quality_issues', 0),
|
|
total_amount=orders_data.get('total_amount', Decimal('0')),
|
|
metrics_data=metrics_data
|
|
)
|
|
|
|
performance_metric = SupplierPerformanceMetric(
|
|
tenant_id=tenant_id,
|
|
supplier_id=supplier_id,
|
|
metric_type=metric_create.metric_type,
|
|
period=metric_create.period,
|
|
period_start=metric_create.period_start,
|
|
period_end=metric_create.period_end,
|
|
metric_value=metric_create.metric_value,
|
|
target_value=metric_create.target_value,
|
|
previous_value=previous_value,
|
|
total_orders=metric_create.total_orders,
|
|
total_deliveries=metric_create.total_deliveries,
|
|
on_time_deliveries=metric_create.on_time_deliveries,
|
|
late_deliveries=metric_create.late_deliveries,
|
|
quality_issues=metric_create.quality_issues,
|
|
total_amount=metric_create.total_amount,
|
|
metrics_data=metric_create.metrics_data,
|
|
trend_direction=trend_direction,
|
|
trend_percentage=trend_percentage,
|
|
calculated_at=datetime.now(timezone.utc)
|
|
)
|
|
|
|
db.add(performance_metric)
|
|
performance_metrics.append(performance_metric)
|
|
|
|
await db.flush()
|
|
|
|
# Update supplier's overall performance ratings
|
|
await self._update_supplier_ratings(db, supplier_id, overall_performance, quality_performance)
|
|
|
|
self.logger.info("Supplier performance calculated successfully",
|
|
supplier_id=str(supplier_id),
|
|
overall_performance=overall_performance)
|
|
|
|
# Return the overall performance metric
|
|
return performance_metrics[0] if performance_metrics else None
|
|
|
|
except Exception as e:
|
|
self.logger.error("Error calculating supplier performance",
|
|
supplier_id=str(supplier_id),
|
|
error=str(e))
|
|
raise
|
|
|
|
async def _get_orders_data(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier_id: UUID,
|
|
tenant_id: UUID,
|
|
period_start: datetime,
|
|
period_end: datetime
|
|
) -> Dict[str, Any]:
|
|
"""Get orders data for performance calculation"""
|
|
query = select(
|
|
func.count(PurchaseOrder.id).label('total_orders'),
|
|
func.sum(PurchaseOrder.total_amount).label('total_amount'),
|
|
func.avg(PurchaseOrder.total_amount).label('avg_order_value'),
|
|
func.count(
|
|
PurchaseOrder.id.filter(
|
|
PurchaseOrder.status == PurchaseOrderStatus.COMPLETED
|
|
)
|
|
).label('completed_orders')
|
|
).where(
|
|
and_(
|
|
PurchaseOrder.supplier_id == supplier_id,
|
|
PurchaseOrder.tenant_id == tenant_id,
|
|
PurchaseOrder.order_date >= period_start,
|
|
PurchaseOrder.order_date <= period_end
|
|
)
|
|
)
|
|
|
|
result = await db.execute(query)
|
|
row = result.first()
|
|
|
|
return {
|
|
'total_orders': row.total_orders or 0,
|
|
'total_amount': row.total_amount or Decimal('0'),
|
|
'avg_order_value': row.avg_order_value or Decimal('0'),
|
|
'completed_orders': row.completed_orders or 0
|
|
}
|
|
|
|
async def _get_deliveries_data(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier_id: UUID,
|
|
tenant_id: UUID,
|
|
period_start: datetime,
|
|
period_end: datetime
|
|
) -> Dict[str, Any]:
|
|
"""Get deliveries data for performance calculation"""
|
|
# Get delivery statistics
|
|
query = select(
|
|
func.count(Delivery.id).label('total_deliveries'),
|
|
func.count(
|
|
Delivery.id.filter(
|
|
and_(
|
|
Delivery.actual_arrival <= Delivery.scheduled_date,
|
|
Delivery.status == DeliveryStatus.DELIVERED
|
|
)
|
|
)
|
|
).label('on_time_deliveries'),
|
|
func.count(
|
|
Delivery.id.filter(
|
|
and_(
|
|
Delivery.actual_arrival > Delivery.scheduled_date,
|
|
Delivery.status == DeliveryStatus.DELIVERED
|
|
)
|
|
)
|
|
).label('late_deliveries'),
|
|
func.avg(
|
|
func.extract('epoch', Delivery.actual_arrival - Delivery.scheduled_date) / 3600
|
|
).label('avg_delay_hours')
|
|
).where(
|
|
and_(
|
|
Delivery.supplier_id == supplier_id,
|
|
Delivery.tenant_id == tenant_id,
|
|
Delivery.scheduled_date >= period_start,
|
|
Delivery.scheduled_date <= period_end,
|
|
Delivery.status.in_([DeliveryStatus.DELIVERED, DeliveryStatus.PARTIALLY_DELIVERED])
|
|
)
|
|
)
|
|
|
|
result = await db.execute(query)
|
|
row = result.first()
|
|
|
|
return {
|
|
'total_deliveries': row.total_deliveries or 0,
|
|
'on_time_deliveries': row.on_time_deliveries or 0,
|
|
'late_deliveries': row.late_deliveries or 0,
|
|
'avg_delay_hours': row.avg_delay_hours or 0
|
|
}
|
|
|
|
async def _get_quality_data(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier_id: UUID,
|
|
tenant_id: UUID,
|
|
period_start: datetime,
|
|
period_end: datetime
|
|
) -> Dict[str, Any]:
|
|
"""Get quality data for performance calculation"""
|
|
query = select(
|
|
func.count(SupplierQualityReview.id).label('total_reviews'),
|
|
func.avg(
|
|
func.cast(SupplierQualityReview.quality_rating, func.Float)
|
|
).label('avg_quality_rating'),
|
|
func.avg(
|
|
func.cast(SupplierQualityReview.delivery_rating, func.Float)
|
|
).label('avg_delivery_rating'),
|
|
func.avg(SupplierQualityReview.communication_rating).label('avg_communication_rating'),
|
|
func.count(
|
|
SupplierQualityReview.id.filter(
|
|
SupplierQualityReview.quality_issues.isnot(None)
|
|
)
|
|
).label('quality_issues')
|
|
).where(
|
|
and_(
|
|
SupplierQualityReview.supplier_id == supplier_id,
|
|
SupplierQualityReview.tenant_id == tenant_id,
|
|
SupplierQualityReview.review_date >= period_start,
|
|
SupplierQualityReview.review_date <= period_end
|
|
)
|
|
)
|
|
|
|
result = await db.execute(query)
|
|
row = result.first()
|
|
|
|
return {
|
|
'total_reviews': row.total_reviews or 0,
|
|
'avg_quality_rating': row.avg_quality_rating or 0,
|
|
'avg_delivery_rating': row.avg_delivery_rating or 0,
|
|
'avg_communication_rating': row.avg_communication_rating or 0,
|
|
'quality_issues': row.quality_issues or 0
|
|
}
|
|
|
|
async def _calculate_delivery_performance(
|
|
self,
|
|
orders_data: Dict[str, Any],
|
|
deliveries_data: Dict[str, Any]
|
|
) -> float:
|
|
"""Calculate delivery performance score (0-100)"""
|
|
total_deliveries = deliveries_data.get('total_deliveries', 0)
|
|
if total_deliveries == 0:
|
|
return 0.0
|
|
|
|
on_time_deliveries = deliveries_data.get('on_time_deliveries', 0)
|
|
on_time_rate = (on_time_deliveries / total_deliveries) * 100
|
|
|
|
# Apply penalty for average delay
|
|
avg_delay_hours = deliveries_data.get('avg_delay_hours', 0)
|
|
delay_penalty = min(avg_delay_hours * 2, 20) # Max 20 point penalty
|
|
|
|
performance_score = max(on_time_rate - delay_penalty, 0)
|
|
return min(performance_score, 100.0)
|
|
|
|
async def _calculate_quality_performance(
|
|
self,
|
|
deliveries_data: Dict[str, Any],
|
|
quality_data: Dict[str, Any]
|
|
) -> float:
|
|
"""Calculate quality performance score (0-100)"""
|
|
total_reviews = quality_data.get('total_reviews', 0)
|
|
if total_reviews == 0:
|
|
return 50.0 # Default score when no reviews
|
|
|
|
# Base quality score from ratings
|
|
avg_quality_rating = quality_data.get('avg_quality_rating', 0)
|
|
base_score = (avg_quality_rating / 5.0) * 100
|
|
|
|
# Apply penalty for quality issues
|
|
quality_issues = quality_data.get('quality_issues', 0)
|
|
issue_penalty = min(quality_issues * 5, 30) # Max 30 point penalty
|
|
|
|
performance_score = max(base_score - issue_penalty, 0)
|
|
return min(performance_score, 100.0)
|
|
|
|
async def _calculate_cost_performance(
|
|
self,
|
|
orders_data: Dict[str, Any],
|
|
deliveries_data: Dict[str, Any]
|
|
) -> float:
|
|
"""Calculate cost performance score (0-100)"""
|
|
# For now, return a baseline score
|
|
# In future, implement price comparison with market rates
|
|
return 75.0
|
|
|
|
async def _calculate_service_performance(
|
|
self,
|
|
orders_data: Dict[str, Any],
|
|
quality_data: Dict[str, Any]
|
|
) -> float:
|
|
"""Calculate service performance score (0-100)"""
|
|
total_reviews = quality_data.get('total_reviews', 0)
|
|
if total_reviews == 0:
|
|
return 50.0 # Default score when no reviews
|
|
|
|
avg_communication_rating = quality_data.get('avg_communication_rating', 0)
|
|
return (avg_communication_rating / 5.0) * 100
|
|
|
|
def _calculate_trend(self, current_value: float, previous_value: Optional[float]) -> Tuple[Optional[str], Optional[float]]:
|
|
"""Calculate performance trend"""
|
|
if previous_value is None or previous_value == 0:
|
|
return None, None
|
|
|
|
change_percentage = ((current_value - previous_value) / previous_value) * 100
|
|
|
|
if abs(change_percentage) < 2: # Less than 2% change considered stable
|
|
trend_direction = "stable"
|
|
elif change_percentage > 0:
|
|
trend_direction = "improving"
|
|
else:
|
|
trend_direction = "declining"
|
|
|
|
return trend_direction, change_percentage
|
|
|
|
async def _get_previous_period_value(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier_id: UUID,
|
|
tenant_id: UUID,
|
|
metric_type: PerformanceMetricType,
|
|
period: PerformancePeriod,
|
|
current_period_start: datetime
|
|
) -> Optional[float]:
|
|
"""Get the previous period's value for trend calculation"""
|
|
# Calculate previous period dates
|
|
if period == PerformancePeriod.DAILY:
|
|
previous_start = current_period_start - timedelta(days=1)
|
|
previous_end = current_period_start
|
|
elif period == PerformancePeriod.WEEKLY:
|
|
previous_start = current_period_start - timedelta(weeks=1)
|
|
previous_end = current_period_start
|
|
elif period == PerformancePeriod.MONTHLY:
|
|
previous_start = current_period_start - timedelta(days=30)
|
|
previous_end = current_period_start
|
|
elif period == PerformancePeriod.QUARTERLY:
|
|
previous_start = current_period_start - timedelta(days=90)
|
|
previous_end = current_period_start
|
|
else: # YEARLY
|
|
previous_start = current_period_start - timedelta(days=365)
|
|
previous_end = current_period_start
|
|
|
|
query = select(SupplierPerformanceMetric.metric_value).where(
|
|
and_(
|
|
SupplierPerformanceMetric.supplier_id == supplier_id,
|
|
SupplierPerformanceMetric.tenant_id == tenant_id,
|
|
SupplierPerformanceMetric.metric_type == metric_type,
|
|
SupplierPerformanceMetric.period == period,
|
|
SupplierPerformanceMetric.period_start >= previous_start,
|
|
SupplierPerformanceMetric.period_start < previous_end
|
|
)
|
|
).order_by(desc(SupplierPerformanceMetric.period_start)).limit(1)
|
|
|
|
result = await db.execute(query)
|
|
row = result.first()
|
|
return row[0] if row else None
|
|
|
|
def _get_target_value(self, metric_type: PerformanceMetricType) -> float:
|
|
"""Get target value for metric type"""
|
|
targets = {
|
|
PerformanceMetricType.DELIVERY_PERFORMANCE: settings.GOOD_DELIVERY_RATE,
|
|
PerformanceMetricType.QUALITY_SCORE: settings.GOOD_QUALITY_RATE,
|
|
PerformanceMetricType.PRICE_COMPETITIVENESS: 80.0,
|
|
PerformanceMetricType.COMMUNICATION_RATING: 80.0,
|
|
PerformanceMetricType.ORDER_ACCURACY: 95.0,
|
|
PerformanceMetricType.RESPONSE_TIME: 90.0,
|
|
PerformanceMetricType.COMPLIANCE_SCORE: 95.0,
|
|
PerformanceMetricType.FINANCIAL_STABILITY: 85.0
|
|
}
|
|
return targets.get(metric_type, 80.0)
|
|
|
|
async def _prepare_detailed_metrics(
|
|
self,
|
|
metric_type: PerformanceMetricType,
|
|
orders_data: Dict[str, Any],
|
|
deliveries_data: Dict[str, Any],
|
|
quality_data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""Prepare detailed metrics breakdown"""
|
|
if metric_type == PerformanceMetricType.DELIVERY_PERFORMANCE:
|
|
return {
|
|
"on_time_rate": (deliveries_data.get('on_time_deliveries', 0) /
|
|
max(deliveries_data.get('total_deliveries', 1), 1)) * 100,
|
|
"avg_delay_hours": deliveries_data.get('avg_delay_hours', 0),
|
|
"late_delivery_count": deliveries_data.get('late_deliveries', 0)
|
|
}
|
|
elif metric_type == PerformanceMetricType.QUALITY_SCORE:
|
|
return {
|
|
"avg_quality_rating": quality_data.get('avg_quality_rating', 0),
|
|
"quality_issues_count": quality_data.get('quality_issues', 0),
|
|
"total_reviews": quality_data.get('total_reviews', 0)
|
|
}
|
|
else:
|
|
return {}
|
|
|
|
async def _update_supplier_ratings(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier_id: UUID,
|
|
overall_performance: float,
|
|
quality_performance: float
|
|
) -> None:
|
|
"""Update supplier's overall ratings"""
|
|
stmt = select(Supplier).where(Supplier.id == supplier_id)
|
|
result = await db.execute(stmt)
|
|
supplier = result.scalar_one_or_none()
|
|
|
|
if supplier:
|
|
supplier.quality_rating = quality_performance / 20 # Convert to 1-5 scale
|
|
supplier.delivery_rating = overall_performance / 20 # Convert to 1-5 scale
|
|
db.add(supplier)
|
|
|
|
|
|
class AlertService:
|
|
"""Service for managing supplier alerts"""
|
|
|
|
def __init__(self):
|
|
self.logger = logger.bind(service="alert_service")
|
|
|
|
@transactional
|
|
async def evaluate_performance_alerts(
|
|
self,
|
|
db: AsyncSession,
|
|
tenant_id: UUID,
|
|
supplier_id: Optional[UUID] = None
|
|
) -> List[SupplierAlert]:
|
|
"""Evaluate and create performance-based alerts"""
|
|
try:
|
|
alerts_created = []
|
|
|
|
# Get suppliers to evaluate
|
|
if supplier_id:
|
|
supplier_filter = and_(Supplier.id == supplier_id, Supplier.tenant_id == tenant_id)
|
|
else:
|
|
supplier_filter = and_(Supplier.tenant_id == tenant_id, Supplier.status == "active")
|
|
|
|
stmt = select(Supplier).where(supplier_filter)
|
|
result = await db.execute(stmt)
|
|
suppliers = result.scalars().all()
|
|
|
|
for supplier in suppliers:
|
|
# Get recent performance metrics
|
|
recent_metrics = await self._get_recent_performance_metrics(db, supplier.id, tenant_id)
|
|
|
|
# Evaluate delivery performance alerts
|
|
delivery_alerts = await self._evaluate_delivery_alerts(db, supplier, recent_metrics)
|
|
alerts_created.extend(delivery_alerts)
|
|
|
|
# Evaluate quality alerts
|
|
quality_alerts = await self._evaluate_quality_alerts(db, supplier, recent_metrics)
|
|
alerts_created.extend(quality_alerts)
|
|
|
|
# Evaluate cost variance alerts
|
|
cost_alerts = await self._evaluate_cost_alerts(db, supplier, recent_metrics)
|
|
alerts_created.extend(cost_alerts)
|
|
|
|
return alerts_created
|
|
|
|
except Exception as e:
|
|
self.logger.error("Error evaluating performance alerts", error=str(e))
|
|
raise
|
|
|
|
async def _get_recent_performance_metrics(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier_id: UUID,
|
|
tenant_id: UUID
|
|
) -> Dict[PerformanceMetricType, SupplierPerformanceMetric]:
|
|
"""Get recent performance metrics for a supplier"""
|
|
query = select(SupplierPerformanceMetric).where(
|
|
and_(
|
|
SupplierPerformanceMetric.supplier_id == supplier_id,
|
|
SupplierPerformanceMetric.tenant_id == tenant_id,
|
|
SupplierPerformanceMetric.calculated_at >= datetime.now(timezone.utc) - timedelta(days=7)
|
|
)
|
|
).order_by(desc(SupplierPerformanceMetric.calculated_at))
|
|
|
|
result = await db.execute(query)
|
|
metrics = result.scalars().all()
|
|
|
|
# Return the most recent metric for each type
|
|
metrics_dict = {}
|
|
for metric in metrics:
|
|
if metric.metric_type not in metrics_dict:
|
|
metrics_dict[metric.metric_type] = metric
|
|
|
|
return metrics_dict
|
|
|
|
async def _evaluate_delivery_alerts(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier: Supplier,
|
|
metrics: Dict[PerformanceMetricType, SupplierPerformanceMetric]
|
|
) -> List[SupplierAlert]:
|
|
"""Evaluate delivery performance alerts"""
|
|
alerts = []
|
|
|
|
delivery_metric = metrics.get(PerformanceMetricType.DELIVERY_PERFORMANCE)
|
|
if not delivery_metric:
|
|
return alerts
|
|
|
|
# Poor delivery performance alert
|
|
if delivery_metric.metric_value < settings.POOR_DELIVERY_RATE:
|
|
severity = AlertSeverity.CRITICAL if delivery_metric.metric_value < 70 else AlertSeverity.HIGH
|
|
|
|
alert = SupplierAlert(
|
|
tenant_id=supplier.tenant_id,
|
|
supplier_id=supplier.id,
|
|
alert_type=AlertType.POOR_QUALITY,
|
|
severity=severity,
|
|
title=f"Poor Delivery Performance - {supplier.name}",
|
|
message=f"Delivery performance has dropped to {delivery_metric.metric_value:.1f}%",
|
|
description=f"Supplier {supplier.name} delivery performance is below acceptable threshold",
|
|
trigger_value=delivery_metric.metric_value,
|
|
threshold_value=settings.POOR_DELIVERY_RATE,
|
|
metric_type=PerformanceMetricType.DELIVERY_PERFORMANCE,
|
|
performance_metric_id=delivery_metric.id,
|
|
priority_score=90 if severity == AlertSeverity.CRITICAL else 70,
|
|
business_impact="high" if severity == AlertSeverity.CRITICAL else "medium",
|
|
recommended_actions=[
|
|
{"action": "Review delivery processes with supplier"},
|
|
{"action": "Request delivery improvement plan"},
|
|
{"action": "Consider alternative suppliers"}
|
|
]
|
|
)
|
|
|
|
db.add(alert)
|
|
alerts.append(alert)
|
|
|
|
return alerts
|
|
|
|
async def _evaluate_quality_alerts(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier: Supplier,
|
|
metrics: Dict[PerformanceMetricType, SupplierPerformanceMetric]
|
|
) -> List[SupplierAlert]:
|
|
"""Evaluate quality performance alerts"""
|
|
alerts = []
|
|
|
|
quality_metric = metrics.get(PerformanceMetricType.QUALITY_SCORE)
|
|
if not quality_metric:
|
|
return alerts
|
|
|
|
# Poor quality performance alert
|
|
if quality_metric.metric_value < settings.POOR_QUALITY_RATE:
|
|
severity = AlertSeverity.CRITICAL if quality_metric.metric_value < 70 else AlertSeverity.HIGH
|
|
|
|
alert = SupplierAlert(
|
|
tenant_id=supplier.tenant_id,
|
|
supplier_id=supplier.id,
|
|
alert_type=AlertType.POOR_QUALITY,
|
|
severity=severity,
|
|
title=f"Poor Quality Performance - {supplier.name}",
|
|
message=f"Quality performance has dropped to {quality_metric.metric_value:.1f}%",
|
|
description=f"Supplier {supplier.name} quality performance is below acceptable threshold",
|
|
trigger_value=quality_metric.metric_value,
|
|
threshold_value=settings.POOR_QUALITY_RATE,
|
|
metric_type=PerformanceMetricType.QUALITY_SCORE,
|
|
performance_metric_id=quality_metric.id,
|
|
priority_score=95 if severity == AlertSeverity.CRITICAL else 75,
|
|
business_impact="high" if severity == AlertSeverity.CRITICAL else "medium",
|
|
recommended_actions=[
|
|
{"action": "Conduct quality audit with supplier"},
|
|
{"action": "Request quality improvement plan"},
|
|
{"action": "Increase incoming inspection frequency"}
|
|
]
|
|
)
|
|
|
|
db.add(alert)
|
|
alerts.append(alert)
|
|
|
|
return alerts
|
|
|
|
async def _evaluate_cost_alerts(
|
|
self,
|
|
db: AsyncSession,
|
|
supplier: Supplier,
|
|
metrics: Dict[PerformanceMetricType, SupplierPerformanceMetric]
|
|
) -> List[SupplierAlert]:
|
|
"""Evaluate cost variance alerts based on historical pricing"""
|
|
alerts = []
|
|
|
|
try:
|
|
from shared.clients.procurement_client import ProcurementServiceClient
|
|
from shared.config.base import get_settings
|
|
from datetime import timedelta
|
|
from collections import defaultdict
|
|
from decimal import Decimal
|
|
|
|
# Configuration thresholds
|
|
WARNING_THRESHOLD = Decimal('0.10') # 10% variance
|
|
CRITICAL_THRESHOLD = Decimal('0.20') # 20% variance
|
|
SAVINGS_THRESHOLD = Decimal('0.10') # 10% decrease
|
|
MIN_SAMPLE_SIZE = 3
|
|
LOOKBACK_DAYS = 30
|
|
|
|
config = get_settings()
|
|
procurement_client = ProcurementServiceClient(config, "suppliers")
|
|
|
|
# Get purchase orders for this supplier from last 60 days (30 days lookback + 30 days current)
|
|
date_to = datetime.now(timezone.utc).date()
|
|
date_from = date_to - timedelta(days=LOOKBACK_DAYS * 2)
|
|
|
|
purchase_orders = await procurement_client.get_purchase_orders_by_supplier(
|
|
tenant_id=str(supplier.tenant_id),
|
|
supplier_id=str(supplier.id),
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
status=None # Get all statuses
|
|
)
|
|
|
|
if not purchase_orders or len(purchase_orders) < MIN_SAMPLE_SIZE:
|
|
self.logger.debug("Insufficient purchase order history for cost variance analysis",
|
|
supplier_id=str(supplier.id),
|
|
po_count=len(purchase_orders) if purchase_orders else 0)
|
|
return alerts
|
|
|
|
# Group items by ingredient/product and calculate price statistics
|
|
ingredient_prices = defaultdict(list)
|
|
cutoff_date = date_to - timedelta(days=LOOKBACK_DAYS)
|
|
|
|
for po in purchase_orders:
|
|
po_date = datetime.fromisoformat(po.get('created_at').replace('Z', '+00:00')).date() if po.get('created_at') else None
|
|
if not po_date:
|
|
continue
|
|
|
|
# Process items in the PO
|
|
for item in po.get('items', []):
|
|
ingredient_id = item.get('ingredient_id')
|
|
ingredient_name = item.get('ingredient_name') or item.get('product_name', 'Unknown')
|
|
unit_price = Decimal(str(item.get('unit_price', 0)))
|
|
|
|
if not ingredient_id or unit_price <= 0:
|
|
continue
|
|
|
|
# Categorize as historical (for baseline) or recent (for comparison)
|
|
is_recent = po_date >= cutoff_date
|
|
ingredient_prices[ingredient_id].append({
|
|
'price': unit_price,
|
|
'date': po_date,
|
|
'name': ingredient_name,
|
|
'is_recent': is_recent
|
|
})
|
|
|
|
# Analyze each ingredient for cost variance
|
|
for ingredient_id, price_history in ingredient_prices.items():
|
|
if len(price_history) < MIN_SAMPLE_SIZE:
|
|
continue
|
|
|
|
# Split into historical baseline and recent prices
|
|
historical_prices = [p['price'] for p in price_history if not p['is_recent']]
|
|
recent_prices = [p['price'] for p in price_history if p['is_recent']]
|
|
|
|
if not historical_prices or not recent_prices:
|
|
continue
|
|
|
|
# Calculate averages
|
|
avg_historical = sum(historical_prices) / len(historical_prices)
|
|
avg_recent = sum(recent_prices) / len(recent_prices)
|
|
|
|
if avg_historical == 0:
|
|
continue
|
|
|
|
# Calculate variance
|
|
variance = (avg_recent - avg_historical) / avg_historical
|
|
ingredient_name = price_history[0]['name']
|
|
|
|
# Generate alerts based on variance
|
|
if variance >= CRITICAL_THRESHOLD:
|
|
# Critical price increase alert
|
|
alert = SupplierAlert(
|
|
tenant_id=supplier.tenant_id,
|
|
supplier_id=supplier.id,
|
|
alert_type=AlertType.cost_variance,
|
|
severity=AlertSeverity.critical,
|
|
status=AlertStatus.active,
|
|
title=f"Critical Price Increase: {ingredient_name}",
|
|
description=(
|
|
f"Significant price increase detected for {ingredient_name}. "
|
|
f"Average price increased from ${avg_historical:.2f} to ${avg_recent:.2f} "
|
|
f"({variance * 100:.1f}% increase) over the last {LOOKBACK_DAYS} days."
|
|
),
|
|
affected_products=ingredient_name,
|
|
detection_date=datetime.now(timezone.utc),
|
|
metadata={
|
|
"ingredient_id": str(ingredient_id),
|
|
"ingredient_name": ingredient_name,
|
|
"avg_historical_price": float(avg_historical),
|
|
"avg_recent_price": float(avg_recent),
|
|
"variance_percent": float(variance * 100),
|
|
"historical_sample_size": len(historical_prices),
|
|
"recent_sample_size": len(recent_prices),
|
|
"lookback_days": LOOKBACK_DAYS
|
|
},
|
|
recommended_actions=[
|
|
{"action": "Contact supplier to negotiate pricing"},
|
|
{"action": "Request explanation for price increase"},
|
|
{"action": "Evaluate alternative suppliers for this ingredient"},
|
|
{"action": "Review contract terms and pricing agreements"}
|
|
]
|
|
)
|
|
db.add(alert)
|
|
alerts.append(alert)
|
|
|
|
elif variance >= WARNING_THRESHOLD:
|
|
# Warning price increase alert
|
|
alert = SupplierAlert(
|
|
tenant_id=supplier.tenant_id,
|
|
supplier_id=supplier.id,
|
|
alert_type=AlertType.cost_variance,
|
|
severity=AlertSeverity.warning,
|
|
status=AlertStatus.active,
|
|
title=f"Price Increase Detected: {ingredient_name}",
|
|
description=(
|
|
f"Moderate price increase detected for {ingredient_name}. "
|
|
f"Average price increased from ${avg_historical:.2f} to ${avg_recent:.2f} "
|
|
f"({variance * 100:.1f}% increase) over the last {LOOKBACK_DAYS} days."
|
|
),
|
|
affected_products=ingredient_name,
|
|
detection_date=datetime.now(timezone.utc),
|
|
metadata={
|
|
"ingredient_id": str(ingredient_id),
|
|
"ingredient_name": ingredient_name,
|
|
"avg_historical_price": float(avg_historical),
|
|
"avg_recent_price": float(avg_recent),
|
|
"variance_percent": float(variance * 100),
|
|
"historical_sample_size": len(historical_prices),
|
|
"recent_sample_size": len(recent_prices),
|
|
"lookback_days": LOOKBACK_DAYS
|
|
},
|
|
recommended_actions=[
|
|
{"action": "Monitor pricing trend over next few orders"},
|
|
{"action": "Contact supplier to discuss pricing"},
|
|
{"action": "Review market prices for this ingredient"}
|
|
]
|
|
)
|
|
db.add(alert)
|
|
alerts.append(alert)
|
|
|
|
elif variance <= -SAVINGS_THRESHOLD:
|
|
# Cost savings opportunity alert
|
|
alert = SupplierAlert(
|
|
tenant_id=supplier.tenant_id,
|
|
supplier_id=supplier.id,
|
|
alert_type=AlertType.cost_variance,
|
|
severity=AlertSeverity.info,
|
|
status=AlertStatus.active,
|
|
title=f"Cost Savings Opportunity: {ingredient_name}",
|
|
description=(
|
|
f"Favorable price decrease detected for {ingredient_name}. "
|
|
f"Average price decreased from ${avg_historical:.2f} to ${avg_recent:.2f} "
|
|
f"({abs(variance) * 100:.1f}% decrease) over the last {LOOKBACK_DAYS} days. "
|
|
f"Consider increasing order volumes to capitalize on lower pricing."
|
|
),
|
|
affected_products=ingredient_name,
|
|
detection_date=datetime.now(timezone.utc),
|
|
metadata={
|
|
"ingredient_id": str(ingredient_id),
|
|
"ingredient_name": ingredient_name,
|
|
"avg_historical_price": float(avg_historical),
|
|
"avg_recent_price": float(avg_recent),
|
|
"variance_percent": float(variance * 100),
|
|
"historical_sample_size": len(historical_prices),
|
|
"recent_sample_size": len(recent_prices),
|
|
"lookback_days": LOOKBACK_DAYS
|
|
},
|
|
recommended_actions=[
|
|
{"action": "Consider increasing order quantities"},
|
|
{"action": "Negotiate long-term pricing lock at current rates"},
|
|
{"action": "Update forecast to account for favorable pricing"}
|
|
]
|
|
)
|
|
db.add(alert)
|
|
alerts.append(alert)
|
|
|
|
if alerts:
|
|
self.logger.info("Cost variance alerts generated",
|
|
supplier_id=str(supplier.id),
|
|
alert_count=len(alerts))
|
|
|
|
except Exception as e:
|
|
self.logger.error("Error evaluating cost variance alerts",
|
|
supplier_id=str(supplier.id),
|
|
error=str(e),
|
|
exc_info=True)
|
|
|
|
return alerts |