New alert system and panel de control page

This commit is contained in:
Urtzi Alfaro
2025-11-27 15:52:40 +01:00
parent 1a2f4602f3
commit e902419b6e
178 changed files with 20982 additions and 6944 deletions

View File

@@ -12,7 +12,6 @@ import structlog
from apscheduler.triggers.cron import CronTrigger
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.alerts.templates import format_item_message
from app.clients.inventory_client import get_inventory_client
logger = structlog.get_logger()

View File

@@ -0,0 +1,359 @@
"""
Forecasting Recommendation Service
Emits RECOMMENDATIONS (not alerts) for demand forecasting insights:
- demand_surge_predicted: Upcoming demand spike
- weather_impact_forecast: Weather affecting demand
- holiday_preparation: Holiday demand prep
- seasonal_trend_insight: Seasonal pattern detected
- inventory_optimization_opportunity: Stock optimization suggestion
These are RECOMMENDATIONS - AI-generated suggestions that are advisory, not urgent.
Users can choose to act on them or dismiss them.
"""
import logging
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
from sqlalchemy.orm import Session
from shared.schemas.event_classification import RawEvent, EventClass, EventDomain
from shared.alerts.base_service import BaseAlertService
logger = logging.getLogger(__name__)
class ForecastingRecommendationService(BaseAlertService):
"""
Service for emitting forecasting recommendations (AI-generated suggestions).
"""
def __init__(self, rabbitmq_url: str = None):
super().__init__(service_name="forecasting", rabbitmq_url=rabbitmq_url)
async def emit_demand_surge_recommendation(
self,
db: Session,
tenant_id: str,
product_sku: str,
product_name: str,
predicted_demand: float,
normal_demand: float,
surge_percentage: float,
surge_date: datetime,
confidence_score: float,
reasoning: str,
) -> None:
"""
Emit RECOMMENDATION for predicted demand surge.
This is a RECOMMENDATION (not alert) - proactive suggestion to prepare.
"""
try:
message = f"{product_name} demand expected to surge by {surge_percentage:.0f}% on {surge_date.strftime('%A, %B %d')} (from {normal_demand:.0f} to {predicted_demand:.0f} units)"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.RECOMMENDATION,
event_domain=EventDomain.DEMAND,
event_type="demand_surge_predicted",
title=f"Demand Surge: {product_name}",
message=message,
service="forecasting",
actions=["increase_production", "check_inventory", "view_forecast"],
event_metadata={
"product_sku": product_sku,
"product_name": product_name,
"predicted_demand": predicted_demand,
"normal_demand": normal_demand,
"surge_percentage": surge_percentage,
"surge_date": surge_date.isoformat(),
"confidence_score": confidence_score,
"reasoning": reasoning,
"estimated_impact": {
"additional_revenue_eur": predicted_demand * 5, # Rough estimate
"stockout_risk": "high" if surge_percentage > 50 else "medium",
},
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="recommendation")
logger.info(
f"Demand surge recommendation emitted: {product_name} (+{surge_percentage:.0f}%)",
extra={"tenant_id": tenant_id, "product_sku": product_sku}
)
except Exception as e:
logger.error(
f"Failed to emit demand surge recommendation: {e}",
extra={"tenant_id": tenant_id, "product_sku": product_sku},
exc_info=True,
)
async def emit_weather_impact_recommendation(
self,
db: Session,
tenant_id: str,
weather_event: str, # 'rain', 'snow', 'heatwave', etc.
forecast_date: datetime,
affected_products: List[Dict[str, Any]],
impact_description: str,
confidence_score: float,
) -> None:
"""
Emit RECOMMENDATION for weather impact on demand.
"""
try:
products_summary = ", ".join([p['product_name'] for p in affected_products[:3]])
if len(affected_products) > 3:
products_summary += f" and {len(affected_products) - 3} more"
message = f"{weather_event.title()} forecast for {forecast_date.strftime('%A')} - {impact_description}"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.RECOMMENDATION,
event_domain=EventDomain.DEMAND,
event_type="weather_impact_forecast",
title=f"Weather Impact: {weather_event.title()}",
message=message,
service="forecasting",
actions=["adjust_production", "view_affected_products"],
event_metadata={
"weather_event": weather_event,
"forecast_date": forecast_date.isoformat(),
"affected_products": affected_products,
"impact_description": impact_description,
"confidence_score": confidence_score,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="recommendation")
logger.info(
f"Weather impact recommendation emitted: {weather_event}",
extra={"tenant_id": tenant_id, "weather_event": weather_event}
)
except Exception as e:
logger.error(
f"Failed to emit weather impact recommendation: {e}",
extra={"tenant_id": tenant_id},
exc_info=True,
)
async def emit_holiday_preparation_recommendation(
self,
db: Session,
tenant_id: str,
holiday_name: str,
holiday_date: datetime,
days_until_holiday: int,
recommended_products: List[Dict[str, Any]],
preparation_tips: List[str],
) -> None:
"""
Emit RECOMMENDATION for holiday preparation.
"""
try:
message = f"{holiday_name} in {days_until_holiday} days - Prepare for increased demand"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.RECOMMENDATION,
event_domain=EventDomain.DEMAND,
event_type="holiday_preparation",
title=f"Prepare for {holiday_name}",
message=message,
service="forecasting",
actions=["view_recommendations", "adjust_orders"],
event_metadata={
"holiday_name": holiday_name,
"holiday_date": holiday_date.isoformat(),
"days_until_holiday": days_until_holiday,
"recommended_products": recommended_products,
"preparation_tips": preparation_tips,
"confidence_score": 0.9, # High confidence for known holidays
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="recommendation")
logger.info(
f"Holiday preparation recommendation emitted: {holiday_name}",
extra={"tenant_id": tenant_id, "holiday": holiday_name}
)
except Exception as e:
logger.error(
f"Failed to emit holiday preparation recommendation: {e}",
extra={"tenant_id": tenant_id},
exc_info=True,
)
async def emit_seasonal_trend_recommendation(
self,
db: Session,
tenant_id: str,
season: str, # 'spring', 'summer', 'fall', 'winter'
trend_type: str, # 'increasing', 'decreasing', 'stable'
affected_categories: List[str],
trend_description: str,
suggested_actions: List[str],
) -> None:
"""
Emit RECOMMENDATION for seasonal trend insight.
"""
try:
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.RECOMMENDATION,
event_domain=EventDomain.DEMAND,
event_type="seasonal_trend_insight",
title=f"Seasonal Trend: {season.title()}",
message=f"{trend_description} - Affects: {', '.join(affected_categories)}",
service="forecasting",
actions=["view_details", "adjust_strategy"],
event_metadata={
"season": season,
"trend_type": trend_type,
"affected_categories": affected_categories,
"trend_description": trend_description,
"suggested_actions": suggested_actions,
"confidence_score": 0.85,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="recommendation")
logger.info(
f"Seasonal trend recommendation emitted: {season}",
extra={"tenant_id": tenant_id, "season": season}
)
except Exception as e:
logger.error(
f"Failed to emit seasonal trend recommendation: {e}",
extra={"tenant_id": tenant_id},
exc_info=True,
)
async def emit_inventory_optimization_recommendation(
self,
db: Session,
tenant_id: str,
ingredient_id: str,
ingredient_name: str,
current_stock: float,
optimal_stock: float,
unit: str,
reason: str,
estimated_savings_eur: Optional[float] = None,
) -> None:
"""
Emit RECOMMENDATION for inventory optimization.
"""
try:
if current_stock > optimal_stock:
action = "reduce"
difference = current_stock - optimal_stock
message = f"Consider reducing {ingredient_name} stock by {difference:.1f} {unit} - {reason}"
else:
action = "increase"
difference = optimal_stock - current_stock
message = f"Consider increasing {ingredient_name} stock by {difference:.1f} {unit} - {reason}"
estimated_impact = {}
if estimated_savings_eur:
estimated_impact["financial_savings_eur"] = estimated_savings_eur
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.RECOMMENDATION,
event_domain=EventDomain.INVENTORY,
event_type="inventory_optimization_opportunity",
title=f"Optimize Stock: {ingredient_name}",
message=message,
service="forecasting",
actions=["adjust_stock", "view_analysis"],
event_metadata={
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"current_stock": current_stock,
"optimal_stock": optimal_stock,
"difference": difference,
"action": action,
"unit": unit,
"reason": reason,
"estimated_impact": estimated_impact if estimated_impact else None,
"confidence_score": 0.75,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="recommendation")
logger.info(
f"Inventory optimization recommendation emitted: {ingredient_name}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
)
except Exception as e:
logger.error(
f"Failed to emit inventory optimization recommendation: {e}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
exc_info=True,
)
async def emit_cost_reduction_recommendation(
self,
db: Session,
tenant_id: str,
opportunity_type: str, # 'supplier_switch', 'bulk_purchase', 'seasonal_buying'
title: str,
description: str,
estimated_savings_eur: float,
suggested_actions: List[str],
details: Dict[str, Any],
) -> None:
"""
Emit RECOMMENDATION for cost reduction opportunity.
"""
try:
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.RECOMMENDATION,
event_domain=EventDomain.SUPPLY_CHAIN,
event_type="cost_reduction_suggestion",
title=title,
message=f"{description} - Potential savings: €{estimated_savings_eur:.2f}",
service="forecasting",
actions=suggested_actions,
event_metadata={
"opportunity_type": opportunity_type,
"estimated_savings_eur": estimated_savings_eur,
"details": details,
"confidence_score": 0.8,
},
timestamp=datetime.now(timezone.utc),
)
await self.publish_item(tenant_id, event.dict(), item_type="recommendation")
logger.info(
f"Cost reduction recommendation emitted: {opportunity_type}",
extra={"tenant_id": tenant_id, "opportunity_type": opportunity_type}
)
except Exception as e:
logger.error(
f"Failed to emit cost reduction recommendation: {e}",
extra={"tenant_id": tenant_id},
exc_info=True,
)