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

@@ -4,8 +4,8 @@ Handles basic CRUD operations for tenants
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Path
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Path, Query
from typing import Dict, Any, List
from uuid import UUID
from app.schemas.tenants import TenantResponse, TenantUpdate
@@ -30,6 +30,47 @@ def get_enhanced_tenant_service():
logger.error("Failed to create enhanced tenant service", error=str(e))
raise HTTPException(status_code=500, detail="Service initialization failed")
@router.get(route_builder.build_base_route("", include_tenant_prefix=False), response_model=List[TenantResponse])
@track_endpoint_metrics("tenants_list")
async def get_active_tenants(
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(100, ge=1, le=1000, description="Maximum number of records to return"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_service: EnhancedTenantService = Depends(get_enhanced_tenant_service)
):
"""Get all active tenants - Available to service accounts and admins"""
logger.info(
"Get active tenants request received",
skip=skip,
limit=limit,
user_id=current_user.get("user_id"),
user_type=current_user.get("type", "user"),
is_service=current_user.get("type") == "service",
role=current_user.get("role"),
service_name=current_user.get("service", "none")
)
# Allow service accounts to call this endpoint
if current_user.get("type") != "service":
# For non-service users, could add additional role checks here if needed
logger.debug(
"Non-service user requesting active tenants",
user_id=current_user.get("user_id"),
role=current_user.get("role")
)
tenants = await tenant_service.get_active_tenants(skip=skip, limit=limit)
logger.debug(
"Get active tenants successful",
count=len(tenants),
skip=skip,
limit=limit
)
return tenants
@router.get(route_builder.build_base_route("{tenant_id}", include_tenant_prefix=False), response_model=TenantResponse)
@track_endpoint_metrics("tenant_get")
async def get_tenant(

View File

@@ -14,6 +14,7 @@ AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .tenants import Tenant, TenantMember, Subscription
from .coupon import CouponModel, CouponRedemptionModel
from .events import Event, EventTemplate
# List all models for easier access
__all__ = [
@@ -23,4 +24,6 @@ __all__ = [
"AuditLog",
"CouponModel",
"CouponRedemptionModel",
"Event",
"EventTemplate",
]

View File

@@ -0,0 +1,136 @@
"""
Event Calendar Models
Database models for tracking local events, promotions, and special occasions
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Boolean, Float, Date
from sqlalchemy.dialects.postgresql import UUID
from shared.database.base import Base
from datetime import datetime, timezone
import uuid
class Event(Base):
"""
Table to track events that affect bakery demand.
Events include:
- Local events (festivals, markets, concerts)
- Promotions and sales
- Weather events (heat waves, storms)
- School holidays and breaks
- Special occasions
"""
__tablename__ = "events"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Event information
event_name = Column(String(500), nullable=False)
event_type = Column(String(100), nullable=False, index=True) # promotion, festival, holiday, weather, school_break, sport_event, etc.
description = Column(Text, nullable=True)
# Date and time
event_date = Column(Date, nullable=False, index=True)
start_time = Column(DateTime(timezone=True), nullable=True)
end_time = Column(DateTime(timezone=True), nullable=True)
is_all_day = Column(Boolean, default=True)
# Impact estimation
expected_impact = Column(String(50), nullable=True) # low, medium, high, very_high
impact_multiplier = Column(Float, nullable=True) # Expected demand multiplier (e.g., 1.5 = 50% increase)
affected_product_categories = Column(String(500), nullable=True) # Comma-separated categories
# Location
location = Column(String(500), nullable=True)
is_local = Column(Boolean, default=True) # True if event is near bakery
# Status
is_confirmed = Column(Boolean, default=False)
is_recurring = Column(Boolean, default=False)
recurrence_pattern = Column(String(200), nullable=True) # e.g., "weekly:monday", "monthly:first_saturday"
# Actual impact (filled after event)
actual_impact_multiplier = Column(Float, nullable=True)
actual_sales_increase_percent = Column(Float, nullable=True)
# Metadata
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
created_by = Column(String(255), nullable=True)
notes = Column(Text, nullable=True)
def to_dict(self):
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"event_name": self.event_name,
"event_type": self.event_type,
"description": self.description,
"event_date": self.event_date.isoformat() if self.event_date else None,
"start_time": self.start_time.isoformat() if self.start_time else None,
"end_time": self.end_time.isoformat() if self.end_time else None,
"is_all_day": self.is_all_day,
"expected_impact": self.expected_impact,
"impact_multiplier": self.impact_multiplier,
"affected_product_categories": self.affected_product_categories,
"location": self.location,
"is_local": self.is_local,
"is_confirmed": self.is_confirmed,
"is_recurring": self.is_recurring,
"recurrence_pattern": self.recurrence_pattern,
"actual_impact_multiplier": self.actual_impact_multiplier,
"actual_sales_increase_percent": self.actual_sales_increase_percent,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_by": self.created_by,
"notes": self.notes
}
class EventTemplate(Base):
"""
Template for recurring events.
Allows easy creation of events based on patterns.
"""
__tablename__ = "event_templates"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Template information
template_name = Column(String(500), nullable=False)
event_type = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
# Default values
default_impact = Column(String(50), nullable=True)
default_impact_multiplier = Column(Float, nullable=True)
default_affected_categories = Column(String(500), nullable=True)
# Recurrence
recurrence_pattern = Column(String(200), nullable=False) # e.g., "weekly:saturday", "monthly:last_sunday"
is_active = Column(Boolean, default=True)
# Metadata
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self):
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"template_name": self.template_name,
"event_type": self.event_type,
"description": self.description,
"default_impact": self.default_impact,
"default_impact_multiplier": self.default_impact_multiplier,
"default_affected_categories": self.default_affected_categories,
"recurrence_pattern": self.recurrence_pattern,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None
}

View File

@@ -154,6 +154,32 @@ class TenantSettings(Base):
"enable_supplier_score_optimization": True
})
# ML Insights Settings (AI Insights Service)
ml_insights_settings = Column(JSON, nullable=False, default=lambda: {
# Inventory ML (Safety Stock Optimization)
"inventory_lookback_days": 90,
"inventory_min_history_days": 30,
# Production ML (Yield Prediction)
"production_lookback_days": 90,
"production_min_history_runs": 30,
# Procurement ML (Supplier Analysis & Price Forecasting)
"supplier_analysis_lookback_days": 180,
"supplier_analysis_min_orders": 10,
"price_forecast_lookback_days": 180,
"price_forecast_horizon_days": 30,
# Forecasting ML (Dynamic Rules)
"rules_generation_lookback_days": 90,
"rules_generation_min_samples": 10,
# Global ML Settings
"enable_ml_insights": True,
"ml_insights_auto_trigger": False,
"ml_confidence_threshold": 0.80
})
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
@@ -280,5 +306,20 @@ class TenantSettings(Base):
"diversification_threshold": 1000,
"max_single_percentage": 0.70,
"enable_supplier_score_optimization": True
},
"ml_insights_settings": {
"inventory_lookback_days": 90,
"inventory_min_history_days": 30,
"production_lookback_days": 90,
"production_min_history_runs": 30,
"supplier_analysis_lookback_days": 180,
"supplier_analysis_min_orders": 10,
"price_forecast_lookback_days": 180,
"price_forecast_horizon_days": 30,
"rules_generation_lookback_days": 90,
"rules_generation_min_samples": 10,
"enable_ml_insights": True,
"ml_insights_auto_trigger": False,
"ml_confidence_threshold": 0.80
}
}

View File

@@ -0,0 +1,283 @@
"""
Event Repository
Data access layer for events
"""
from typing import List, Optional, Dict, Any
from datetime import date, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_, func
from uuid import UUID
import structlog
from app.models.events import Event, EventTemplate
from shared.database.repository import BaseRepository
logger = structlog.get_logger()
class EventRepository(BaseRepository[Event]):
"""Repository for event management"""
def __init__(self, session: AsyncSession):
super().__init__(Event, session)
async def get_events_by_date_range(
self,
tenant_id: UUID,
start_date: date,
end_date: date,
event_types: List[str] = None,
confirmed_only: bool = False
) -> List[Event]:
"""
Get events within a date range.
Args:
tenant_id: Tenant UUID
start_date: Start date (inclusive)
end_date: End date (inclusive)
event_types: Optional filter by event types
confirmed_only: Only return confirmed events
Returns:
List of Event objects
"""
try:
query = select(Event).where(
and_(
Event.tenant_id == tenant_id,
Event.event_date >= start_date,
Event.event_date <= end_date
)
)
if event_types:
query = query.where(Event.event_type.in_(event_types))
if confirmed_only:
query = query.where(Event.is_confirmed == True)
query = query.order_by(Event.event_date)
result = await self.session.execute(query)
events = result.scalars().all()
logger.debug("Retrieved events by date range",
tenant_id=str(tenant_id),
start_date=start_date.isoformat(),
end_date=end_date.isoformat(),
count=len(events))
return list(events)
except Exception as e:
logger.error("Failed to get events by date range",
tenant_id=str(tenant_id),
error=str(e))
return []
async def get_events_for_date(
self,
tenant_id: UUID,
event_date: date
) -> List[Event]:
"""
Get all events for a specific date.
Args:
tenant_id: Tenant UUID
event_date: Date to get events for
Returns:
List of Event objects
"""
try:
query = select(Event).where(
and_(
Event.tenant_id == tenant_id,
Event.event_date == event_date
)
).order_by(Event.start_time)
result = await self.session.execute(query)
events = result.scalars().all()
return list(events)
except Exception as e:
logger.error("Failed to get events for date",
tenant_id=str(tenant_id),
error=str(e))
return []
async def get_upcoming_events(
self,
tenant_id: UUID,
days_ahead: int = 30,
limit: int = 100
) -> List[Event]:
"""
Get upcoming events.
Args:
tenant_id: Tenant UUID
days_ahead: Number of days to look ahead
limit: Maximum number of events to return
Returns:
List of upcoming Event objects
"""
try:
from datetime import date, timedelta
today = date.today()
future_date = today + timedelta(days=days_ahead)
query = select(Event).where(
and_(
Event.tenant_id == tenant_id,
Event.event_date >= today,
Event.event_date <= future_date
)
).order_by(Event.event_date).limit(limit)
result = await self.session.execute(query)
events = result.scalars().all()
return list(events)
except Exception as e:
logger.error("Failed to get upcoming events",
tenant_id=str(tenant_id),
error=str(e))
return []
async def create_event(self, event_data: Dict[str, Any]) -> Event:
"""Create a new event"""
try:
event = Event(**event_data)
self.session.add(event)
await self.session.flush()
logger.info("Created event",
event_id=str(event.id),
event_name=event.event_name,
event_date=event.event_date.isoformat())
return event
except Exception as e:
logger.error("Failed to create event", error=str(e))
raise
async def update_event_actual_impact(
self,
event_id: UUID,
actual_impact_multiplier: float,
actual_sales_increase_percent: float
) -> Optional[Event]:
"""
Update event with actual impact after it occurs.
Args:
event_id: Event UUID
actual_impact_multiplier: Actual demand multiplier observed
actual_sales_increase_percent: Actual sales increase percentage
Returns:
Updated Event or None
"""
try:
event = await self.get(event_id)
if not event:
return None
event.actual_impact_multiplier = actual_impact_multiplier
event.actual_sales_increase_percent = actual_sales_increase_percent
await self.session.flush()
logger.info("Updated event actual impact",
event_id=str(event_id),
actual_multiplier=actual_impact_multiplier)
return event
except Exception as e:
logger.error("Failed to update event actual impact",
event_id=str(event_id),
error=str(e))
return None
async def get_events_by_type(
self,
tenant_id: UUID,
event_type: str,
limit: int = 100
) -> List[Event]:
"""Get events by type"""
try:
query = select(Event).where(
and_(
Event.tenant_id == tenant_id,
Event.event_type == event_type
)
).order_by(Event.event_date.desc()).limit(limit)
result = await self.session.execute(query)
events = result.scalars().all()
return list(events)
except Exception as e:
logger.error("Failed to get events by type",
tenant_id=str(tenant_id),
event_type=event_type,
error=str(e))
return []
class EventTemplateRepository(BaseRepository[EventTemplate]):
"""Repository for event template management"""
def __init__(self, session: AsyncSession):
super().__init__(EventTemplate, session)
async def get_active_templates(self, tenant_id: UUID) -> List[EventTemplate]:
"""Get all active event templates for a tenant"""
try:
query = select(EventTemplate).where(
and_(
EventTemplate.tenant_id == tenant_id,
EventTemplate.is_active == True
)
).order_by(EventTemplate.template_name)
result = await self.session.execute(query)
templates = result.scalars().all()
return list(templates)
except Exception as e:
logger.error("Failed to get active templates",
tenant_id=str(tenant_id),
error=str(e))
return []
async def create_template(self, template_data: Dict[str, Any]) -> EventTemplate:
"""Create a new event template"""
try:
template = EventTemplate(**template_data)
self.session.add(template)
await self.session.flush()
logger.info("Created event template",
template_id=str(template.id),
template_name=template.template_name)
return template
except Exception as e:
logger.error("Failed to create event template", error=str(e))
raise

View File

@@ -184,7 +184,7 @@ class SupplierSelectionSettings(BaseModel):
@validator('price_weight', 'lead_time_weight', 'quality_weight', 'reliability_weight')
def validate_weights_sum(cls, v, values):
weights = [values.get('price_weight', 0.40), values.get('lead_time_weight', 0.20),
weights = [values.get('price_weight', 0.40), values.get('lead_time_weight', 0.20),
values.get('quality_weight', 0.20), values.get('reliability_weight', 0.20)]
total = sum(weights)
if total > 1.0:
@@ -192,6 +192,32 @@ class SupplierSelectionSettings(BaseModel):
return v
class MLInsightsSettings(BaseModel):
"""ML Insights configuration settings"""
# Inventory ML (Safety Stock Optimization)
inventory_lookback_days: int = Field(90, ge=30, le=365, description="Days of demand history for safety stock analysis")
inventory_min_history_days: int = Field(30, ge=7, le=180, description="Minimum days of history required")
# Production ML (Yield Prediction)
production_lookback_days: int = Field(90, ge=30, le=365, description="Days of production history for yield analysis")
production_min_history_runs: int = Field(30, ge=10, le=100, description="Minimum production runs required")
# Procurement ML (Supplier Analysis & Price Forecasting)
supplier_analysis_lookback_days: int = Field(180, ge=30, le=730, description="Days of order history for supplier analysis")
supplier_analysis_min_orders: int = Field(10, ge=5, le=100, description="Minimum orders required for analysis")
price_forecast_lookback_days: int = Field(180, ge=90, le=730, description="Days of price history for forecasting")
price_forecast_horizon_days: int = Field(30, ge=7, le=90, description="Days to forecast ahead")
# Forecasting ML (Dynamic Rules)
rules_generation_lookback_days: int = Field(90, ge=30, le=365, description="Days of sales history for rule learning")
rules_generation_min_samples: int = Field(10, ge=5, le=100, description="Minimum samples required for rule generation")
# Global ML Settings
enable_ml_insights: bool = Field(True, description="Enable/disable ML insights generation")
ml_insights_auto_trigger: bool = Field(False, description="Automatically trigger ML insights in daily workflow")
ml_confidence_threshold: float = Field(0.80, ge=0.0, le=1.0, description="Minimum confidence threshold for ML recommendations")
# ================================================================
# REQUEST/RESPONSE SCHEMAS
# ================================================================
@@ -210,6 +236,7 @@ class TenantSettingsResponse(BaseModel):
safety_stock_settings: SafetyStockSettings
moq_settings: MOQSettings
supplier_selection_settings: SupplierSelectionSettings
ml_insights_settings: MLInsightsSettings
created_at: datetime
updated_at: datetime
@@ -229,6 +256,7 @@ class TenantSettingsUpdate(BaseModel):
safety_stock_settings: Optional[SafetyStockSettings] = None
moq_settings: Optional[MOQSettings] = None
supplier_selection_settings: Optional[SupplierSelectionSettings] = None
ml_insights_settings: Optional[MLInsightsSettings] = None
class CategoryUpdateRequest(BaseModel):

View File

@@ -265,18 +265,34 @@ class EnhancedTenantService:
async def get_user_tenants(self, owner_id: str) -> List[TenantResponse]:
"""Get all tenants owned by a user"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
tenants = await self.tenant_repo.get_tenants_by_owner(owner_id)
return [TenantResponse.from_orm(tenant) for tenant in tenants]
except Exception as e:
logger.error("Error getting user tenants",
owner_id=owner_id,
error=str(e))
return []
async def get_active_tenants(self, skip: int = 0, limit: int = 100) -> List[TenantResponse]:
"""Get all active tenants"""
try:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
tenants = await self.tenant_repo.get_active_tenants(skip=skip, limit=limit)
return [TenantResponse.from_orm(tenant) for tenant in tenants]
except Exception as e:
logger.error("Error getting active tenants",
skip=skip,
limit=limit,
error=str(e))
return []
async def search_tenants(
self,