REFACTOR ALL APIs fix 1

This commit is contained in:
Urtzi Alfaro
2025-10-07 07:15:07 +02:00
parent 38fb98bc27
commit 7c72f83c51
47 changed files with 1821 additions and 270 deletions

View File

@@ -51,17 +51,26 @@ class OnboardingRepository:
return None
async def upsert_user_step(
self,
user_id: str,
step_name: str,
completed: bool,
step_data: Dict[str, Any] = None
self,
user_id: str,
step_name: str,
completed: bool,
step_data: Dict[str, Any] = None,
auto_commit: bool = True
) -> UserOnboardingProgress:
"""Insert or update a user's onboarding step"""
"""Insert or update a user's onboarding step
Args:
user_id: User ID
step_name: Name of the step
completed: Whether the step is completed
step_data: Additional data for the step
auto_commit: Whether to auto-commit (set to False when used within UnitOfWork)
"""
try:
completed_at = datetime.now(timezone.utc) if completed else None
step_data = step_data or {}
# Use PostgreSQL UPSERT (INSERT ... ON CONFLICT ... DO UPDATE)
stmt = insert(UserOnboardingProgress).values(
user_id=user_id,
@@ -71,7 +80,7 @@ class OnboardingRepository:
step_data=step_data,
updated_at=datetime.now(timezone.utc)
)
# On conflict, update the existing record
stmt = stmt.on_conflict_do_update(
index_elements=['user_id', 'step_name'],
@@ -82,17 +91,24 @@ class OnboardingRepository:
updated_at=stmt.excluded.updated_at
)
)
# Return the updated record
stmt = stmt.returning(UserOnboardingProgress)
result = await self.db.execute(stmt)
await self.db.commit()
# Only commit if auto_commit is True (not within a UnitOfWork)
if auto_commit:
await self.db.commit()
else:
# Flush to ensure the statement is executed
await self.db.flush()
return result.scalars().first()
except Exception as e:
logger.error(f"Error upserting step {step_name} for user {user_id}: {e}")
await self.db.rollback()
if auto_commit:
await self.db.rollback()
raise
async def get_user_summary(self, user_id: str) -> Optional[UserOnboardingSummary]:

View File

@@ -108,16 +108,15 @@ class EnhancedAuthService:
}
await token_repo.create_token(token_data)
# Commit transaction
await uow.commit()
# Store subscription plan selection in onboarding progress for later retrieval
# Store subscription plan selection in onboarding progress BEFORE committing
# This ensures it's part of the same transaction
if user_data.subscription_plan or user_data.use_trial or user_data.payment_method_id:
try:
from app.repositories.onboarding_repository import OnboardingRepository
from app.models.onboarding import UserOnboardingProgress
# Use upsert_user_step instead of save_step_data to avoid double commits
onboarding_repo = OnboardingRepository(db_session)
plan_data = {
"subscription_plan": user_data.subscription_plan or "starter",
@@ -126,17 +125,29 @@ class EnhancedAuthService:
"saved_at": datetime.now(timezone.utc).isoformat()
}
await onboarding_repo.save_step_data(
str(new_user.id),
"user_registered",
plan_data
# Create the onboarding step record with plan data
# Note: We use completed=True to mark user_registered as complete
# auto_commit=False to let UnitOfWork handle the commit
await onboarding_repo.upsert_user_step(
user_id=str(new_user.id),
step_name="user_registered",
completed=True,
step_data=plan_data,
auto_commit=False
)
logger.info("Subscription plan saved to onboarding progress",
user_id=new_user.id,
plan=user_data.subscription_plan)
except Exception as e:
logger.warning("Failed to save subscription plan to onboarding progress", error=str(e))
logger.error("Failed to save subscription plan to onboarding progress",
user_id=new_user.id,
error=str(e))
# Re-raise to ensure registration fails if onboarding data can't be saved
raise
# Commit transaction (includes user, tokens, and onboarding data)
await uow.commit()
# Publish registration event (non-blocking)
try:

View File

@@ -50,7 +50,7 @@ async def extend_demo_session(
"status": session.status.value,
"created_at": session.created_at,
"expires_at": session.expires_at,
"demo_config": session.metadata.get("demo_config", {}),
"demo_config": session.session_metadata.get("demo_config", {}),
"session_token": session_token
}

View File

@@ -82,7 +82,7 @@ async def create_demo_session(
"status": session.status.value,
"created_at": session.created_at,
"expires_at": session.expires_at,
"demo_config": session.metadata.get("demo_config", {}),
"demo_config": session.session_metadata.get("demo_config", {}),
"session_token": session_token
}

View File

@@ -59,6 +59,7 @@ class DemoSession(Base):
return {
"id": str(self.id),
"session_id": self.session_id,
"user_id": str(self.user_id) if self.user_id else None,
"virtual_tenant_id": str(self.virtual_tenant_id),
"base_demo_tenant_id": str(self.base_demo_tenant_id),
"demo_account_type": self.demo_account_type,

View File

@@ -73,7 +73,7 @@ class DemoSessionManager:
last_activity_at=datetime.now(timezone.utc),
data_cloned=False,
redis_populated=False,
metadata={
session_metadata={
"demo_config": demo_config,
"extension_count": 0
}
@@ -133,7 +133,7 @@ class DemoSessionManager:
raise ValueError(f"Cannot extend {session.status.value} session")
# Check extension limit
extension_count = session.metadata.get("extension_count", 0)
extension_count = session.session_metadata.get("extension_count", 0)
if extension_count >= settings.DEMO_SESSION_MAX_EXTENSIONS:
raise ValueError(f"Maximum extensions ({settings.DEMO_SESSION_MAX_EXTENSIONS}) reached")
@@ -144,7 +144,7 @@ class DemoSessionManager:
session.expires_at = new_expires_at
session.last_activity_at = datetime.now(timezone.utc)
session.metadata["extension_count"] = extension_count + 1
session.session_metadata["extension_count"] = extension_count + 1
await self.db.commit()
await self.db.refresh(session)

View File

@@ -0,0 +1,421 @@
"""
Scenario Simulation Operations API - PROFESSIONAL/ENTERPRISE ONLY
Business operations for "what-if" scenario testing and strategic planning
"""
import structlog
from fastapi import APIRouter, Depends, HTTPException, status, Path, Request
from typing import List, Dict, Any
from datetime import date, datetime, timedelta
import uuid
from app.schemas.forecasts import (
ScenarioSimulationRequest,
ScenarioSimulationResponse,
ScenarioComparisonRequest,
ScenarioComparisonResponse,
ScenarioType,
ScenarioImpact,
ForecastResponse,
ForecastRequest
)
from app.services.forecasting_service import EnhancedForecastingService
from shared.auth.decorators import get_current_user_dep
from shared.database.base import create_database_manager
from shared.monitoring.decorators import track_execution_time
from shared.monitoring.metrics import get_metrics_collector
from app.core.config import settings
from shared.routing import RouteBuilder
from shared.auth.access_control import require_user_role
route_builder = RouteBuilder('forecasting')
logger = structlog.get_logger()
router = APIRouter(tags=["scenario-simulation"])
def get_enhanced_forecasting_service():
"""Dependency injection for EnhancedForecastingService"""
database_manager = create_database_manager(settings.DATABASE_URL, "forecasting-service")
return EnhancedForecastingService(database_manager)
@router.post(
route_builder.build_analytics_route("scenario-simulation"),
response_model=ScenarioSimulationResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@track_execution_time("scenario_simulation_duration_seconds", "forecasting-service")
async def simulate_scenario(
request: ScenarioSimulationRequest,
tenant_id: str = Path(..., description="Tenant ID"),
request_obj: Request = None,
forecasting_service: EnhancedForecastingService = Depends(get_enhanced_forecasting_service)
):
"""
Run a "what-if" scenario simulation on forecasts
This endpoint allows users to test how different scenarios might impact demand:
- Weather events (heatwaves, cold snaps, rain)
- Competition (new competitors opening nearby)
- Events (festivals, concerts, sports events)
- Pricing changes
- Promotions
- Supply disruptions
**PROFESSIONAL/ENTERPRISE ONLY**
"""
metrics = get_metrics_collector(request_obj)
start_time = datetime.utcnow()
try:
logger.info("Starting scenario simulation",
tenant_id=tenant_id,
scenario_name=request.scenario_name,
scenario_type=request.scenario_type.value,
products=len(request.inventory_product_ids))
if metrics:
metrics.increment_counter(f"scenario_simulations_total")
metrics.increment_counter(f"scenario_simulations_{request.scenario_type.value}_total")
# Generate simulation ID
simulation_id = str(uuid.uuid4())
end_date = request.start_date + timedelta(days=request.duration_days - 1)
# Step 1: Generate baseline forecasts
baseline_forecasts = []
if request.include_baseline:
logger.info("Generating baseline forecasts", tenant_id=tenant_id)
for product_id in request.inventory_product_ids:
forecast_request = ForecastRequest(
inventory_product_id=product_id,
forecast_date=request.start_date,
forecast_days=request.duration_days,
location="default" # TODO: Get from tenant settings
)
multi_day_result = await forecasting_service.generate_multi_day_forecast(
tenant_id=tenant_id,
request=forecast_request
)
baseline_forecasts.extend(multi_day_result.get("forecasts", []))
# Step 2: Apply scenario adjustments to generate scenario forecasts
scenario_forecasts = await _apply_scenario_adjustments(
tenant_id=tenant_id,
request=request,
baseline_forecasts=baseline_forecasts if request.include_baseline else [],
forecasting_service=forecasting_service
)
# Step 3: Calculate impacts
product_impacts = _calculate_product_impacts(
baseline_forecasts,
scenario_forecasts,
request.inventory_product_ids
)
# Step 4: Calculate totals
total_baseline_demand = sum(f.predicted_demand for f in baseline_forecasts) if baseline_forecasts else 0
total_scenario_demand = sum(f.predicted_demand for f in scenario_forecasts)
overall_impact_percent = (
((total_scenario_demand - total_baseline_demand) / total_baseline_demand * 100)
if total_baseline_demand > 0 else 0
)
# Step 5: Generate insights and recommendations
insights, recommendations, risk_level = _generate_insights(
request.scenario_type,
request,
product_impacts,
overall_impact_percent
)
# Calculate processing time
processing_time_ms = int((datetime.utcnow() - start_time).total_seconds() * 1000)
if metrics:
metrics.increment_counter("scenario_simulations_success_total")
metrics.observe_histogram("scenario_simulation_processing_time_ms", processing_time_ms)
logger.info("Scenario simulation completed successfully",
tenant_id=tenant_id,
simulation_id=simulation_id,
overall_impact=f"{overall_impact_percent:.2f}%",
processing_time_ms=processing_time_ms)
return ScenarioSimulationResponse(
id=simulation_id,
tenant_id=tenant_id,
scenario_name=request.scenario_name,
scenario_type=request.scenario_type,
start_date=request.start_date,
end_date=end_date,
duration_days=request.duration_days,
baseline_forecasts=baseline_forecasts if request.include_baseline else None,
scenario_forecasts=scenario_forecasts,
total_baseline_demand=total_baseline_demand,
total_scenario_demand=total_scenario_demand,
overall_impact_percent=overall_impact_percent,
product_impacts=product_impacts,
insights=insights,
recommendations=recommendations,
risk_level=risk_level,
created_at=datetime.utcnow(),
processing_time_ms=processing_time_ms
)
except ValueError as e:
if metrics:
metrics.increment_counter("scenario_simulation_validation_errors_total")
logger.error("Scenario simulation validation error", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
if metrics:
metrics.increment_counter("scenario_simulations_errors_total")
logger.error("Scenario simulation failed", error=str(e), tenant_id=tenant_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Scenario simulation failed"
)
async def _apply_scenario_adjustments(
tenant_id: str,
request: ScenarioSimulationRequest,
baseline_forecasts: List[ForecastResponse],
forecasting_service: EnhancedForecastingService
) -> List[ForecastResponse]:
"""
Apply scenario-specific adjustments to forecasts
"""
scenario_forecasts = []
# If no baseline, generate fresh forecasts
if not baseline_forecasts:
for product_id in request.inventory_product_ids:
forecast_request = ForecastRequest(
inventory_product_id=product_id,
forecast_date=request.start_date,
forecast_days=request.duration_days,
location="default"
)
multi_day_result = await forecasting_service.generate_multi_day_forecast(
tenant_id=tenant_id,
request=forecast_request
)
baseline_forecasts = multi_day_result.get("forecasts", [])
# Apply multipliers based on scenario type
for forecast in baseline_forecasts:
adjusted_forecast = forecast.copy()
multiplier = _get_scenario_multiplier(request)
# Adjust predicted demand
adjusted_forecast.predicted_demand *= multiplier
adjusted_forecast.confidence_lower *= multiplier
adjusted_forecast.confidence_upper *= multiplier
scenario_forecasts.append(adjusted_forecast)
return scenario_forecasts
def _get_scenario_multiplier(request: ScenarioSimulationRequest) -> float:
"""
Calculate demand multiplier based on scenario type and parameters
"""
if request.scenario_type == ScenarioType.WEATHER:
if request.weather_params:
# Heatwave increases demand for cold items, decreases for hot items
if request.weather_params.temperature_change and request.weather_params.temperature_change > 10:
return 1.25 # 25% increase during heatwave
elif request.weather_params.temperature_change and request.weather_params.temperature_change < -10:
return 0.85 # 15% decrease during cold snap
elif request.weather_params.precipitation_change and request.weather_params.precipitation_change > 10:
return 0.90 # 10% decrease during heavy rain
return 1.0
elif request.scenario_type == ScenarioType.COMPETITION:
if request.competition_params:
# New competition reduces demand based on market share loss
return 1.0 - request.competition_params.estimated_market_share_loss
return 0.85 # Default 15% reduction
elif request.scenario_type == ScenarioType.EVENT:
if request.event_params:
# Events increase demand based on attendance and proximity
if request.event_params.distance_km < 1.0:
return 1.5 # 50% increase for very close events
elif request.event_params.distance_km < 5.0:
return 1.2 # 20% increase for nearby events
return 1.15 # Default 15% increase
elif request.scenario_type == ScenarioType.PRICING:
if request.pricing_params:
# Price elasticity: typically -0.5 to -2.0
# 10% price increase = 5-20% demand decrease
elasticity = -1.0 # Average elasticity
return 1.0 + (request.pricing_params.price_change_percent / 100) * elasticity
return 1.0
elif request.scenario_type == ScenarioType.PROMOTION:
if request.promotion_params:
# Promotions increase traffic and conversion
traffic_boost = 1.0 + request.promotion_params.expected_traffic_increase
discount_boost = 1.0 + (request.promotion_params.discount_percent / 100) * 0.5
return traffic_boost * discount_boost
return 1.3 # Default 30% increase
elif request.scenario_type == ScenarioType.SUPPLY_DISRUPTION:
return 0.6 # 40% reduction due to limited supply
elif request.scenario_type == ScenarioType.CUSTOM:
if request.custom_multipliers and 'demand' in request.custom_multipliers:
return request.custom_multipliers['demand']
return 1.0
return 1.0
def _calculate_product_impacts(
baseline_forecasts: List[ForecastResponse],
scenario_forecasts: List[ForecastResponse],
product_ids: List[str]
) -> List[ScenarioImpact]:
"""
Calculate per-product impact of the scenario
"""
impacts = []
for product_id in product_ids:
baseline_total = sum(
f.predicted_demand for f in baseline_forecasts
if f.inventory_product_id == product_id
)
scenario_total = sum(
f.predicted_demand for f in scenario_forecasts
if f.inventory_product_id == product_id
)
if baseline_total > 0:
change_percent = ((scenario_total - baseline_total) / baseline_total) * 100
else:
change_percent = 0
# Get confidence ranges
scenario_product_forecasts = [
f for f in scenario_forecasts if f.inventory_product_id == product_id
]
avg_lower = sum(f.confidence_lower for f in scenario_product_forecasts) / len(scenario_product_forecasts) if scenario_product_forecasts else 0
avg_upper = sum(f.confidence_upper for f in scenario_product_forecasts) / len(scenario_product_forecasts) if scenario_product_forecasts else 0
impacts.append(ScenarioImpact(
inventory_product_id=product_id,
baseline_demand=baseline_total,
simulated_demand=scenario_total,
demand_change_percent=change_percent,
confidence_range=(avg_lower, avg_upper),
impact_factors={"primary_driver": "scenario_adjustment"}
))
return impacts
def _generate_insights(
scenario_type: ScenarioType,
request: ScenarioSimulationRequest,
impacts: List[ScenarioImpact],
overall_impact: float
) -> tuple[List[str], List[str], str]:
"""
Generate AI-powered insights and recommendations
"""
insights = []
recommendations = []
risk_level = "low"
# Determine risk level
if abs(overall_impact) > 30:
risk_level = "high"
elif abs(overall_impact) > 15:
risk_level = "medium"
# Generate scenario-specific insights
if scenario_type == ScenarioType.WEATHER:
if request.weather_params:
if request.weather_params.temperature_change and request.weather_params.temperature_change > 10:
insights.append(f"Heatwave of +{request.weather_params.temperature_change}°C expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Increase inventory of cold beverages and refrigerated items")
recommendations.append("Extend operating hours to capture increased evening traffic")
elif request.weather_params.temperature_change and request.weather_params.temperature_change < -10:
insights.append(f"Cold snap of {request.weather_params.temperature_change}°C expected to decrease demand by {abs(overall_impact):.1f}%")
recommendations.append("Increase production of warm comfort foods")
recommendations.append("Reduce inventory of cold items")
elif scenario_type == ScenarioType.COMPETITION:
insights.append(f"New competitor expected to reduce demand by {abs(overall_impact):.1f}%")
recommendations.append("Consider launching loyalty program to retain customers")
recommendations.append("Differentiate with unique product offerings")
recommendations.append("Focus on customer service excellence")
elif scenario_type == ScenarioType.EVENT:
insights.append(f"Local event expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Increase staffing for the event period")
recommendations.append("Stock additional inventory of popular items")
recommendations.append("Consider event-specific promotions")
elif scenario_type == ScenarioType.PRICING:
if overall_impact < 0:
insights.append(f"Price increase expected to reduce demand by {abs(overall_impact):.1f}%")
recommendations.append("Consider smaller price increases")
recommendations.append("Communicate value proposition to customers")
else:
insights.append(f"Price decrease expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Ensure adequate inventory to meet increased demand")
elif scenario_type == ScenarioType.PROMOTION:
insights.append(f"Promotion expected to increase demand by {overall_impact:.1f}%")
recommendations.append("Stock additional inventory before promotion starts")
recommendations.append("Increase staffing during promotion period")
recommendations.append("Prepare marketing materials and signage")
# Add product-specific insights
high_impact_products = [
impact for impact in impacts
if abs(impact.demand_change_percent) > 20
]
if high_impact_products:
insights.append(f"{len(high_impact_products)} products show significant impact (>20% change)")
# Add general recommendation
if risk_level == "high":
recommendations.append("⚠️ High-impact scenario - review and adjust operational plans immediately")
elif risk_level == "medium":
recommendations.append("Monitor situation closely and prepare contingency plans")
return insights, recommendations, risk_level
@router.post(
route_builder.build_analytics_route("scenario-comparison"),
response_model=ScenarioComparisonResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def compare_scenarios(
request: ScenarioComparisonRequest,
tenant_id: str = Path(..., description="Tenant ID")
):
"""
Compare multiple scenario simulations
**PROFESSIONAL/ENTERPRISE ONLY**
"""
# TODO: Implement scenario comparison
# This would retrieve saved scenarios and compare them
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Scenario comparison not yet implemented"
)

View File

@@ -15,7 +15,7 @@ from app.services.forecasting_alert_service import ForecastingAlertService
from shared.service_base import StandardFastAPIService
# Import API routers
from app.api import forecasts, forecasting_operations, analytics
from app.api import forecasts, forecasting_operations, analytics, scenario_operations
class ForecastingService(StandardFastAPIService):
@@ -166,6 +166,7 @@ service.setup_custom_endpoints()
service.add_router(forecasts.router)
service.add_router(forecasting_operations.router)
service.add_router(analytics.router)
service.add_router(scenario_operations.router)
if __name__ == "__main__":
import uvicorn

View File

@@ -107,3 +107,166 @@ class MultiDayForecastResponse(BaseModel):
processing_time_ms: int = Field(..., description="Total processing time")
# ================================================================
# SCENARIO SIMULATION SCHEMAS - PROFESSIONAL/ENTERPRISE ONLY
# ================================================================
class ScenarioType(str, Enum):
"""Types of scenarios available for simulation"""
WEATHER = "weather" # Weather impact (heatwave, cold snap, rain, etc.)
COMPETITION = "competition" # New competitor opening nearby
EVENT = "event" # Local event (festival, sports, concert, etc.)
PRICING = "pricing" # Price changes
PROMOTION = "promotion" # Promotional campaigns
HOLIDAY = "holiday" # Holiday periods
SUPPLY_DISRUPTION = "supply_disruption" # Supply chain issues
CUSTOM = "custom" # Custom user-defined scenario
class WeatherScenario(BaseModel):
"""Weather scenario parameters"""
temperature_change: Optional[float] = Field(None, ge=-30, le=30, description="Temperature change in °C")
precipitation_change: Optional[float] = Field(None, ge=0, le=100, description="Precipitation change in mm")
weather_type: Optional[str] = Field(None, description="Weather type (heatwave, cold_snap, rainy, etc.)")
class CompetitionScenario(BaseModel):
"""Competition scenario parameters"""
new_competitors: int = Field(1, ge=1, le=10, description="Number of new competitors")
distance_km: float = Field(0.5, ge=0.1, le=10, description="Distance from location in km")
estimated_market_share_loss: float = Field(0.1, ge=0, le=0.5, description="Estimated market share loss (0-50%)")
class EventScenario(BaseModel):
"""Event scenario parameters"""
event_type: str = Field(..., description="Type of event (festival, sports, concert, etc.)")
expected_attendance: int = Field(..., ge=0, description="Expected attendance")
distance_km: float = Field(0.5, ge=0, le=50, description="Distance from location in km")
duration_days: int = Field(1, ge=1, le=30, description="Duration in days")
class PricingScenario(BaseModel):
"""Pricing scenario parameters"""
price_change_percent: float = Field(..., ge=-50, le=100, description="Price change percentage")
affected_products: Optional[List[str]] = Field(None, description="List of affected product IDs")
class PromotionScenario(BaseModel):
"""Promotion scenario parameters"""
discount_percent: float = Field(..., ge=0, le=75, description="Discount percentage")
promotion_type: str = Field(..., description="Type of promotion (bogo, discount, bundle, etc.)")
expected_traffic_increase: float = Field(0.2, ge=0, le=2, description="Expected traffic increase (0-200%)")
class ScenarioSimulationRequest(BaseModel):
"""Request schema for scenario simulation - PROFESSIONAL/ENTERPRISE ONLY"""
scenario_name: str = Field(..., min_length=3, max_length=200, description="Name for this scenario")
scenario_type: ScenarioType = Field(..., description="Type of scenario to simulate")
inventory_product_ids: List[str] = Field(..., min_items=1, description="Products to simulate")
start_date: date = Field(..., description="Simulation start date")
duration_days: int = Field(7, ge=1, le=30, description="Simulation duration in days")
# Scenario-specific parameters (one should be provided based on scenario_type)
weather_params: Optional[WeatherScenario] = None
competition_params: Optional[CompetitionScenario] = None
event_params: Optional[EventScenario] = None
pricing_params: Optional[PricingScenario] = None
promotion_params: Optional[PromotionScenario] = None
# Custom scenario parameters
custom_multipliers: Optional[Dict[str, float]] = Field(
None,
description="Custom multipliers for baseline forecast (e.g., {'demand': 1.2, 'traffic': 0.8})"
)
# Comparison settings
include_baseline: bool = Field(True, description="Include baseline forecast for comparison")
@validator('start_date')
def validate_start_date(cls, v):
if v < date.today():
raise ValueError("Simulation start date cannot be in the past")
return v
class ScenarioImpact(BaseModel):
"""Impact of scenario on a specific product"""
inventory_product_id: str
baseline_demand: float
simulated_demand: float
demand_change_percent: float
confidence_range: tuple[float, float]
impact_factors: Dict[str, Any] # Breakdown of what drove the change
class ScenarioSimulationResponse(BaseModel):
"""Response schema for scenario simulation"""
id: str = Field(..., description="Simulation ID")
tenant_id: str
scenario_name: str
scenario_type: ScenarioType
# Simulation parameters
start_date: date
end_date: date
duration_days: int
# Results
baseline_forecasts: Optional[List[ForecastResponse]] = Field(
None,
description="Baseline forecasts (if requested)"
)
scenario_forecasts: List[ForecastResponse] = Field(..., description="Forecasts with scenario applied")
# Impact summary
total_baseline_demand: float
total_scenario_demand: float
overall_impact_percent: float
product_impacts: List[ScenarioImpact]
# Insights and recommendations
insights: List[str] = Field(..., description="AI-generated insights about the scenario")
recommendations: List[str] = Field(..., description="Actionable recommendations")
risk_level: str = Field(..., description="Risk level: low, medium, high")
# Metadata
created_at: datetime
processing_time_ms: int
class Config:
json_schema_extra = {
"example": {
"id": "scenario_123",
"tenant_id": "tenant_456",
"scenario_name": "Summer Heatwave Impact",
"scenario_type": "weather",
"overall_impact_percent": 15.5,
"insights": [
"Cold beverages expected to increase by 45%",
"Bread products may decrease by 8% due to reduced appetite",
"Ice cream demand projected to surge by 120%"
],
"recommendations": [
"Increase cold beverage inventory by 40%",
"Reduce bread production by 10%",
"Stock additional ice cream varieties"
],
"risk_level": "medium"
}
}
class ScenarioComparisonRequest(BaseModel):
"""Request to compare multiple scenarios"""
scenario_ids: List[str] = Field(..., min_items=2, max_items=5, description="Scenario IDs to compare")
class ScenarioComparisonResponse(BaseModel):
"""Response comparing multiple scenarios"""
scenarios: List[ScenarioSimulationResponse]
comparison_matrix: Dict[str, Dict[str, Any]]
best_case_scenario_id: str
worst_case_scenario_id: str
recommended_action: str

View File

@@ -116,7 +116,7 @@ async def get_food_safety_dashboard(
route_builder.build_dashboard_route("analytics"),
response_model=InventoryAnalytics
)
@analytics_tier_required
async def get_inventory_analytics(
tenant_id: UUID = Path(...),
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),

View File

@@ -89,7 +89,6 @@ async def acknowledge_alert(
route_builder.build_analytics_route("food-safety-metrics"),
response_model=FoodSafetyMetrics
)
@analytics_tier_required
async def get_food_safety_metrics(
tenant_id: UUID = Path(...),
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),

View File

@@ -60,7 +60,7 @@ async def get_orders_service(db = Depends(get_db)) -> OrdersService:
# ===== Dashboard and Analytics Endpoints =====
@router.get(
route_builder.build_base_route("dashboard-summary"),
route_builder.build_operations_route("dashboard-summary"),
response_model=OrdersDashboardSummary
)
async def get_dashboard_summary(

View File

@@ -110,6 +110,7 @@ async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> Procure
@monitor_performance("get_current_procurement_plan")
async def get_current_procurement_plan(
tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -137,6 +138,7 @@ async def get_current_procurement_plan(
async def get_procurement_plan_by_date(
tenant_id: uuid.UUID,
plan_date: date,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -168,7 +170,7 @@ async def list_procurement_plans(
end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"),
limit: int = Query(50, ge=1, le=100, description="Number of plans to return"),
offset: int = Query(0, ge=0, description="Number of plans to skip"),
# tenant_access: TenantAccess = Depends(get_current_tenant),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
@@ -226,6 +228,7 @@ async def list_procurement_plans(
async def generate_procurement_plan(
tenant_id: uuid.UUID,
request: GeneratePlanRequest,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -272,6 +275,7 @@ async def update_procurement_plan_status(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -314,6 +318,7 @@ async def update_procurement_plan_status(
async def get_procurement_plan_by_id(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -354,6 +359,7 @@ async def get_procurement_plan_by_id(
@monitor_performance("get_procurement_dashboard")
async def get_procurement_dashboard(
tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -392,6 +398,7 @@ async def get_plan_requirements(
plan_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by requirement status"),
priority: Optional[str] = Query(None, description="Filter by priority level"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -436,6 +443,7 @@ async def get_plan_requirements(
@monitor_performance("get_critical_requirements")
async def get_critical_requirements(
tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -469,6 +477,7 @@ async def get_critical_requirements(
async def recalculate_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -514,6 +523,7 @@ async def link_requirement_to_purchase_order(
purchase_order_number: str,
ordered_quantity: float,
expected_delivery_date: Optional[date] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -572,6 +582,7 @@ async def update_requirement_delivery_status(
received_quantity: Optional[float] = None,
actual_delivery_date: Optional[date] = None,
quality_rating: Optional[float] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -627,6 +638,7 @@ async def approve_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
approval_notes: Optional[str] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -683,6 +695,7 @@ async def reject_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
rejection_notes: Optional[str] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
@@ -739,6 +752,7 @@ async def create_purchase_orders_from_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
auto_approve: bool = False,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):

View File

@@ -18,6 +18,7 @@ from ..schemas.recipes import (
)
from shared.routing import RouteBuilder, RouteCategory
from shared.auth.access_control import require_user_role
from shared.auth.decorators import get_current_user_dep
route_builder = RouteBuilder('recipes')
logger = logging.getLogger(__name__)
@@ -148,8 +149,10 @@ async def check_recipe_feasibility(
route_builder.build_dashboard_route("statistics"),
response_model=RecipeStatisticsResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_recipe_statistics(
tenant_id: UUID,
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""Get recipe statistics for dashboard"""

View File

@@ -36,7 +36,7 @@ logger = structlog.get_logger()
# ===== Supplier Operations =====
@router.get(route_builder.build_operations_route("suppliers/statistics"), response_model=SupplierStatistics)
@router.get(route_builder.build_operations_route("statistics"), response_model=SupplierStatistics)
async def get_supplier_statistics(
tenant_id: str = Path(..., description="Tenant ID"),
db: AsyncSession = Depends(get_db)

View File

@@ -587,16 +587,48 @@ async def upgrade_subscription_plan(
detail=validation.get("reason", "Cannot upgrade to this plan")
)
# TODO: Implement actual plan upgrade logic
# This would involve:
# 1. Update subscription in database
# 2. Process payment changes
# 3. Update billing cycle
# 4. Send notifications
# Actually update the subscription plan in the database
from app.core.config import settings
from app.repositories.subscription_repository import SubscriptionRepository
from app.models.tenants import Subscription
from shared.database.base import create_database_manager
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
async with database_manager.get_session() as session:
subscription_repo = SubscriptionRepository(Subscription, session)
# Get the active subscription for this tenant
active_subscription = await subscription_repo.get_active_subscription(str(tenant_id))
if not active_subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active subscription found for this tenant"
)
# Update the subscription plan
updated_subscription = await subscription_repo.update_subscription_plan(
str(active_subscription.id),
new_plan
)
# Commit the changes
await session.commit()
logger.info("Subscription plan upgraded successfully",
tenant_id=str(tenant_id),
subscription_id=str(active_subscription.id),
old_plan=active_subscription.plan,
new_plan=new_plan,
user_id=current_user["user_id"])
return {
"success": True,
"message": f"Plan upgrade to {new_plan} initiated",
"message": f"Plan successfully upgraded to {new_plan}",
"old_plan": active_subscription.plan,
"new_plan": new_plan,
"new_monthly_price": updated_subscription.monthly_price,
"validation": validation
}

View File

@@ -478,6 +478,18 @@ async def training_progress_websocket(
await connection_manager.connect(websocket, job_id, connection_id)
logger.info(f"WebSocket connection established for job {job_id}, user {user_id}")
# Send immediate connection confirmation to prevent gateway timeout
try:
await websocket.send_json({
"type": "connected",
"job_id": job_id,
"message": "WebSocket connection established",
"timestamp": str(datetime.now())
})
logger.debug(f"Sent connection confirmation for job {job_id}")
except Exception as e:
logger.error(f"Failed to send connection confirmation for job {job_id}: {e}")
consumer_task = None
training_completed = False